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, work queues, and atomic state. Each recipe is a complete, self-contained sketch; 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. On a map with neither LRU nor TTL they run under the shared read lock; with either enabled they take the write lock -- still atomic, just serialised.

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 under a single lock, 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)[0 .. 9];

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 -- so returning its result directly hands the caller undef for a value that was just computed successfully. 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 (clock/second-chance; reads stay lock-free).

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, 0, 0, 0, $arena_cap). Eviction is driven by the entry count alone, so an undersized arena stops the cache dead: once it is full every put needing arena space returns false, no eviction happens, and the cache keeps its oldest keys forever. (Keys and values of 7 bytes or fewer are stored inline and keep working, so the cache can look alive while it takes nothing real.) Check put's return value, and watch stats->{arena_used} against stats->{arena_cap}.

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 a TTL map with no LRU fills with expired ids that nothing reclaims (see the rate limiter below), and from then on every new event reads as "already processed" and is silently dropped. Reclaim on a timer, or give the map a $max_size:

my ($flushed, $done) = $seen->flush_expired_partial(1000);

put_ttl/set_ttl set a per-key TTL; persist makes a key permanent; ttl_remaining reports seconds left. TTL is measured on a monotonic clock.

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, 2s TTL
my $reg = Data::HashMap::Shared::IS->new('/tmp/live.shm', 4096, 0, 2);

# 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.

Token-bucket rate limiting

A counter per subject with a TTL window; the first hit creates it (TTL applies) and subsequent hits increment it. Note that 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: a TTL map with no LRU does not reclaim them until something asks for that key, so the table eventually fills and incr starts dying for every new subject while keys reports none. Reclaim on a timer -- a bounded slice per tick is enough:

# e.g. once a second from your event loop
my ($flushed, $done) = $rl->flush_expired_partial(1000);

Giving the map a $max_size instead makes eviction reclaim the slots for you.

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);

# 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_size for 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. Pick the smallest count that relieves contention: lookups get slower as the count rises (see "Sharding" in Data::HashMap::Shared), and the count is not stored in the files, 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.