NAME
Data::HashMap::Shared::Cookbook - Practical recipes for shared-memory maps
DESCRIPTION
Copy-paste patterns for common cross-process tasks with Data::HashMap::Shared: counters, caches, rate limiting, liveness, dedup, and atomic state. Runnable versions of several live under eg/ in the distribution.
Pick a variant by key/value type (see "CHOOSING A VARIANT") and replace the shm_xx_* keyword prefix accordingly (shm_ii_, shm_ss_, shm_si_, ...).
RECIPES
Shared request/error counters
Pre-forked workers tallying into one map. incr/incr_by are atomic, so no update is lost.
use Data::HashMap::Shared::SI; # string metric name -> int64 count
my $stats = Data::HashMap::Shared::SI->new('/tmp/stats.shm', 1000);
# in each worker, per request:
shm_si_incr $stats, 'requests';
shm_si_incr_by $stats, 'bytes_out', $n;
shm_si_incr $stats, 'errors' if $failed;
# anytime, from any process:
my %snapshot = %{ $stats->to_hash };
For write-heavy counters spread the load with new_sharded (see eg/sharded_counter.pl).
High-water marks and leaderboards
max/min store max($current, $desired) / min(...) atomically, so concurrent updates never clobber each other or lose a higher score. A missing key is inserted as the given value.
use Data::HashMap::Shared::SI; # player -> high score
my $board = Data::HashMap::Shared::SI->new('/tmp/board.shm', 10_000);
shm_si_max $board, $player, $score; # keeps only the best, race-free
# keep unrelated metrics in their own map -- anything in $board is a player
my $scores = $board->to_hash;
my @top = sort { $scores->{$b} <=> $scores->{$a} } keys %$scores;
@top = @top[0 .. 9] if @top > 10; # a slice would pad a short board with undef
Full example: eg/leaderboard.pl.
Cache-aside memoization (compute once)
get_or_set stores a key's value once; racing callers all receive the same stored value, so an expensive computation is shared across the fleet.
use Data::HashMap::Shared::IS; # int id -> string result
my $cache = Data::HashMap::Shared::IS->new('/tmp/memo.shm', 100_000);
sub lookup {
my $id = shift;
my $hit = shm_is_get $cache, $id;
return $hit if defined $hit; # fast path
my $fresh = compute($id);
my $stored = shm_is_get_or_set $cache, $id, $fresh; # first writer wins
return defined $stored ? $stored : $fresh;
}
get_or_set returns undef when the entry could not be stored at all -- the table is full, or (string variants) the arena is exhausted. Fall back to the computed value as above, and give a long-lived cache a $max_size so eviction keeps slots free.
Full example: eg/memoize.pl.
LRU cache with bounded memory
Pass $max_size to cap live entries; the least-recently-used entry is evicted on insert.
use Data::HashMap::Shared::SS;
# max_entries=1_000_000 table, evict once 100_000 entries are live:
my $cache = Data::HashMap::Shared::SS->new('/tmp/cache.shm', 1_000_000, 100_000);
shm_ss_put $cache, $key, $value; # auto-evicts LRU when full
my $v = shm_ss_get $cache, $key; # also refreshes recency (clock bit)
my $evicted = $cache->stats->{evictions};
For large values on a small table, size the string arena explicitly so a few big entries do not starve it: ->new($path, $max_entries, $max_size, 0, 0, $arena_cap). An insert that cannot fit evicts one entry and retries, so size the arena for the working set: one that only just holds it evicts on nearly every insert, so the hit rate collapses while stats->{evictions} climbs, and an entry needing a size class none of the oldest entries holds still fails.
Per-key TTL and dedup / idempotency
A default TTL expires entries lazily on access; add inserts only if absent. Together they make a "process each id once within a window" guard.
use Data::HashMap::Shared::IS; # event id -> marker, 300s TTL
my $seen = Data::HashMap::Shared::IS->new('/tmp/seen.shm', 1_000_000, 0, 300);
if ($seen->add($event_id, '1')) { # true only the first time (then TTL'd)
handle($event_id);
} # else: already processed recently, skip
add is false both when the id was already seen and when the entry could not be stored, and the two are indistinguishable. Event ids never repeat, so the table fills with expired ids; a timer keeps the dead weight from lengthening every probe. Size its slice as under "Sliding-window rate limiting", or give the map a $max_size:
# once a second: the 2097152 slots this map can grow to, over a 300s TTL
my ($flushed, $done) = $seen->flush_expired_partial(7000);
put_ttl/set_ttl set a per-key TTL; persist makes a key permanent; ttl_remaining reports seconds left.
Worker heartbeat / liveness registry
Each worker refreshes a TTL'd heartbeat; a dead worker stops refreshing and its entry expires, so a supervisor sees only live workers.
use Data::HashMap::Shared::IS; # pid -> status, 3s TTL for a 1s refresh
my $reg = Data::HashMap::Shared::IS->new('/tmp/live.shm', 4096, 0, 3);
# worker loop:
shm_is_put $reg, $$, 'ok'; # every second; resets the TTL
# supervisor:
$reg->flush_expired; # drop stale heartbeats
my @live = $reg->keys;
Full example: eg/heartbeat.pl.
Sliding-window rate limiting
A counter per subject with a TTL window; the first hit creates it (TTL applies) and subsequent hits increment it. Each incr refreshes the entry's TTL, so the window slides: it resets only once a subject stops hitting it for a full window, and a client that keeps retrying stays limited until it backs off.
use Data::HashMap::Shared::SI; # subject -> hits, 60s window
my $rl = Data::HashMap::Shared::SI->new('/tmp/rl.shm', 100_000, 0, 60);
sub allow {
my $who = shift;
my $n = shm_si_incr $rl, $who; # auto-creates at 1 with the default TTL
return $n <= 100; # cap per window
}
Subjects that never come back leave expired entries holding their slots until an insert needs one: an insert that finds the table full flushes every dead subject at once, which keeps the limiter working. Reclaim on a timer as well, to keep size honest and the probes short between those flushes; the slice counts slots scanned, not entries freed, so size it to cycle the whole table inside one window, the slots the map can grow to over the window in ticks. size climbing while keys stays flat means the slice is losing ground:
# once a second: the 262144 slots this map can grow to, over the 60s window
my ($flushed, $done) = $rl->flush_expired_partial(5000);
Giving the map a $max_size instead makes eviction reclaim the slots for you and needs no timer at all.
Full example: eg/rate_limiter.pl.
Atomic state machine (compare-and-swap)
cas swaps only if the current value matches, so processes can drive a shared state machine without a lock.
use Data::HashMap::Shared::SI; # job -> state code (0=idle 1=running 2=done)
my $jobs = Data::HashMap::Shared::SI->new('/tmp/jobs.shm', 100_000);
shm_si_add $jobs, $_, 0 for @job_ids; # cas needs the key to exist first
# claim a job: idle(0) -> running(1), exactly one winner
if (shm_si_cas $jobs, $job, 0, 1) {
run($job);
shm_si_cas $jobs, $job, 1, 2; # running -> done
}
cas_take atomically removes a key only if it matches (handy for one-shot claims); see also the work-queue pattern in eg/work_queue.pl.
CHOOSING A VARIANT
Variants are <key><value> where I16/I32/I are signed 16/32/64-bit integers and S is a byte string. Integer variants store values inline (no arena, fastest); string sides use the shared arena.
II int64 -> int64 counters, ids-to-ids
I16 int16 -> int16 compact enums / small counters
I32 int32 -> int32
IS int64 -> string id -> blob/json (memoization)
I16S int16 -> string
I32S int32 -> string
SI string -> int64 named counters, scores, rate limits
SI16 string -> int16
SI32 string -> int32
SS string -> string config, sessions, generic caches
Use the narrowest integer width that fits your range (values wrap at the variant's width; see "Integer Range and Wrapping" in Data::HashMap::Shared). The incr/decr/incr_by/max/min counter ops exist only on integer-value variants.
SIZING
->new($path, $max_entries, $max_size, $ttl, $lru_skip, $arena_cap)
$max_entries -- table capacity (it grows/shrinks elastically up to this). Round up for your peak live-key count.
$max_size -- LRU cap (0 = no eviction). Set to bound memory; entries beyond it evict least-recently-used.
$ttl -- default per-entry TTL in seconds (0 = none). Set for caches / windows; combine with
$max_sizefor a bounded TTL cache.$lru_skip -- LRU promotion-skip percentage (0-99, default 0). Leave at 0; raise only if profiling shows write-lock contention from LRU promotions on a Zipfian workload. See "Constructor" in Data::HashMap::Shared.
$arena_cap -- string-storage bytes (0 =
max(max_entries*128, 4096)). Set explicitly when a few large string keys/values would otherwise exhaust the default; integer-only variants ignore it. Each string occupies the next power of two at or above its length (16-byte minimum), so size from the rounded lengths, not the raw byte total.shards (
new_sharded($prefix, $shards, ...)) -- independent maps with independent locks for write-heavy workloads; per-key ops route automatically, and the sizing args above are per shard. A set created by 0.20 or later records its count and croaks if opened with a different one; an older set does not, so every process must pass the same one.
SEE ALSO
Data::HashMap::Shared for the full API; the eg/ directory for runnable versions of these recipes.
AUTHOR
vividsnow
LICENSE
Same terms as Perl itself.