<!-- category: snippets -->
Self-Expiring Hash Entries via tie
You write$cache{$key} = $value. Two minutes later, it is gone.
Nobody deleted it. No cron job. No background thread. The hash
entry simply expired, like milk left on the counter.
Normal hash syntax. Normal hash behavior. But every entry has a time-to-live. When you fetch a key past its expiration, the hash pretends it was never there. No external modules. No event loops. Just Perl's tie mechanism doing what it does best: making ordinary syntax do extraordinary things.my %cache; tie %cache, 'ExpiringHash', ttl => 60; $cache{session_abc} = { user => 'dave', role => 'admin' }; # 59 seconds later... say $cache{session_abc}{user}; # dave # 61 seconds later... say $cache{session_abc}; # undef (gone)
Part 1: THE CONCEPT
A TTL cache is simple. Every value gets a timestamp. When you read a key, check the timestamp. If too much time has passed, the entry is dead. Return undef. Clean it up.The trick is making this transparent. You do not want to writeSTORE "foo" => "bar" at time 1000 TTL = 60 seconds expires at 1060 FETCH "foo" at time 1030 => "bar" (alive, 30s left) FETCH "foo" at time 1059 => "bar" (alive, 1s left) FETCH "foo" at time 1061 => undef (expired, deleted)
$cache->get_if_not_expired('foo') everywhere. You want plain
$cache{foo}. Perl's tie interface lets you intercept every hash
operation and inject your expiry logic behind the scenes.
Part 2: THE TIEHASH INTERFACE
To tie a hash, you write a class that implements specific methods. Perl calls these methods automatically whenever someone uses the tied hash:You do not call these methods yourself. Perl calls them for you. Your code just usesOPERATION METHOD CALLED ------------------- ---------------------- tie %hash, 'Class' TIEHASH (constructor) $hash{key} FETCH $hash{key} = val STORE delete $hash{key} DELETE exists $hash{key} EXISTS keys %hash FIRSTKEY / NEXTKEY %hash = () CLEAR untie %hash DESTROY
$hash{key} and the magic happens underneath.
Part 3: THE IMPLEMENTATION
Here is the complete module. It stores each value alongside its insertion timestamp, and checks expiry on every FETCH:That is the whole thing. About 80 lines. Every hash operation is intercepted, and expiry is checked on every read.package ExpiringHash; use strict; use warnings; sub TIEHASH { my ($class, %opts) = @_; my $ttl = $opts{ttl} || 300; # default 5 minutes return bless { _data => {}, _ttl => $ttl, }, $class; } sub STORE { my ($self, $key, $value) = @_; $self->{_data}{$key} = { value => $value, timestamp => time(), }; } sub FETCH { my ($self, $key) = @_; return undef unless exists $self->{_data}{$key}; my $entry = $self->{_data}{$key}; if (time() - $entry->{timestamp} > $self->{_ttl}) { delete $self->{_data}{$key}; # expired, clean up return undef; } return $entry->{value}; } sub EXISTS { my ($self, $key) = @_; return 0 unless exists $self->{_data}{$key}; my $entry = $self->{_data}{$key}; if (time() - $entry->{timestamp} > $self->{_ttl}) { delete $self->{_data}{$key}; return 0; } return 1; } sub DELETE { my ($self, $key) = @_; delete $self->{_data}{$key}; } sub CLEAR { my ($self) = @_; $self->{_data} = {}; } sub FIRSTKEY { my ($self) = @_; $self->_purge_expired(); my @keys = keys %{ $self->{_data} }; $self->{_iter} = \@keys; return shift @{ $self->{_iter} }; } sub NEXTKEY { my ($self, $lastkey) = @_; return shift @{ $self->{_iter} }; } sub DESTROY { } sub _purge_expired { my ($self) = @_; my $now = time(); for my $key (keys %{ $self->{_data} }) { my $entry = $self->{_data}{$key}; if ($now - $entry->{timestamp} > $self->{_ttl}) { delete $self->{_data}{$key}; } } } 1;
Part 4: USING IT
Drop the module in your project and tie away:From the outside, it looks and smells like a normal hash. The TTL logic is invisible. Code that receivesuse strict; use warnings; use feature 'say'; # assuming ExpiringHash.pm is in your @INC use ExpiringHash; my %cache; tie %cache, 'ExpiringHash', ttl => 10; $cache{greeting} = "hello"; $cache{count} = 42; say $cache{greeting}; # hello say exists $cache{count}; # 1 say scalar keys %cache; # 2 sleep 11; say $cache{greeting} // "gone"; # gone say exists $cache{count}; # 0 say scalar keys %cache; # 0
%cache as a parameter
does not need to know or care that entries expire. It just uses
hash syntax like always.
Part 5: PER-KEY TTL
The basic version gives every key the same TTL. But what if you want different lifetimes for different entries? Easy. Change STORE to accept a TTL alongside the value:Now you can do:sub STORE { my ($self, $key, $value) = @_; # if value is an arrayref [data, ttl], use custom TTL if (ref $value eq 'ARRAY' && @$value == 2) { $self->{_data}{$key} = { value => $value->[0], timestamp => time(), ttl => $value->[1], }; } else { $self->{_data}{$key} = { value => $value, timestamp => time(), ttl => $self->{_ttl}, }; } } sub FETCH { my ($self, $key) = @_; return undef unless exists $self->{_data}{$key}; my $entry = $self->{_data}{$key}; my $ttl = $entry->{ttl} // $self->{_ttl}; if (time() - $entry->{timestamp} > $ttl) { delete $self->{_data}{$key}; return undef; } return $entry->{value}; }
The convention is a bit ugly. You lose the ability to store plain arrayrefs as values unless you wrap them. A cleaner approach would be a separate method on the tied object, but that breaks the "just use hash syntax" goal. Trade-offs.$cache{short_lived} = ["temporary data", 5]; # 5 second TTL $cache{long_lived} = ["persistent data", 3600]; # 1 hour TTL $cache{normal} = "just a string"; # default TTL
Part 6: PRACTICAL USE: RATE LIMITING
Throttle API calls. Allow 5 requests per minute per IP:use strict; use warnings; use feature 'say'; my %rate; tie %rate, 'ExpiringHash', ttl => 60; sub check_rate_limit { my ($ip) = @_; $rate{$ip} = 0 unless defined $rate{$ip}; $rate{$ip}++; if ($rate{$ip} > 5) { return 0; # rate limited } return 1; # allowed } # simulate requests for my $i (1 .. 8) { my $allowed = check_rate_limit('192.168.1.1'); say "Request $i: " . ($allowed ? "OK" : "BLOCKED"); }
Wait 60 seconds, and the counter expires. The IP gets a fresh allowance. No cleanup code. No timers. The hash just forgets.Request 1: OK Request 2: OK Request 3: OK Request 4: OK Request 5: OK Request 6: BLOCKED Request 7: BLOCKED Request 8: BLOCKED
Part 7: PRACTICAL USE: SESSION STORAGE
Web session data that auto-expires:Themy %sessions; tie %sessions, 'ExpiringHash', ttl => 1800; # 30 minutes sub create_session { my ($user) = @_; my $sid = generate_session_id(); $sessions{$sid} = { user => $user, created => time(), last_seen => time(), }; return $sid; } sub get_session { my ($sid) = @_; return $sessions{$sid}; # undef if expired } sub touch_session { my ($sid) = @_; if (my $data = $sessions{$sid}) { $data->{last_seen} = time(); $sessions{$sid} = $data; # re-STORE resets the clock } }
touch_session function is the clever bit. Re-storing the
value resets the timestamp, so active sessions keep living. Idle
sessions die after 30 minutes of silence. Exactly how session
timeouts should work.
Part 8: PRACTICAL USE: DNS CACHE
Cache DNS lookups with the same TTL the DNS server gave you:Simple. No CPAN dependencies. No background refresh threads. The cache is self-managing because the hash is self-expiring.use Socket; my %dns; tie %dns, 'ExpiringHash', ttl => 300; # 5 minute default sub resolve { my ($hostname) = @_; if (exists $dns{$hostname}) { return $dns{$hostname}; # cached } my $packed = gethostbyname($hostname); return undef unless $packed; my $ip = inet_ntoa($packed); $dns{$hostname} = $ip; # cache it return $ip; } # first call hits DNS say resolve('perl.org'); # second call uses cache (fast) say resolve('perl.org'); # after 5 minutes, cache expires, next call hits DNS again
Part 9: COMPARISON TO REAL CACHE MODULES
For production systems, you should probably know about CHI and Cache::Cache:The self-expiring hash is perfect for small scripts, prototyping, and situations where you want zero dependencies. For anything with serious traffic, persistent storage, or multi-process access, reach for CHI.FEATURE ExpiringHash CHI Cache::Cache ------------------ ------------ ----------- ------------ Install required no (DIY) yes (CPAN) yes (CPAN) Backend options memory only many many Per-key TTL with hack native native Namespaces no yes yes Serialization no yes yes Max size / eviction no yes yes Thread safe no configurable no Tested in prod by you by thousands by thousands
But here is the thing. Most of the time, you are writing a script that needs to cache 50 DNS lookups or throttle a few API calls. You do not need a full caching framework for that. An 80-line tied hash does the job.
Part 10: THE TIE PHILOSOPHY
The beauty of tie is not that it lets you build caches. It is that it lets you build caches that look like hashes.Any code that works with a hash works with your tied hash. You can
pass %cache to a function that has no idea entries expire. You
can dump it with Data::Dumper. You can grep its keys. You can
slice it.
The tie interface is one of Perl's most underrated features. It turns any variable into an API without changing the syntax. Your hash is not just a hash. It is a hash with opinions about time.# all of these work on the tied hash my @active = grep { exists $cache{$_} } @keys; my @vals = @cache{@some_keys}; my $count = scalar keys %cache;
A hash that forgets is surprisingly useful. Rate limiters, session stores, DNS caches, circuit breakers, dedup windows. All of them boil down to "remember this for a while, then stop.".--. |o_o | "Your keys have an expiration date. |:_/ | Check the label." // \ \ (| | ) /'\_ _/`\ \___)=(___/
Perl does not have built-in TTL hashes. But it has tie, which means you can build one in 80 lines and never think about it again. The hash remembers so you do not have to. And then it forgets, which is even better.
perl.gg