NAME
Shared::Arena::Map - a fixed-capacity map several processes share
VERSION
Version 0.09
SYNOPSIS
my $arena = Shared::Arena->create(size => 8 * 1024 * 1024);
my $map = $arena->map('cache', slots => 4096, slot_size => 512);
$map->store('user:42', $blob);
my ($blob) = $map->fetch('user:42');
# counters are one atomic, so a rate limit costs nothing to share
my $hits = $map->incr("rate:$ip");
DESCRIPTION
A map in an arena, written and read by any number of processes. Reads take no lock. Writes take one of a stripe of locks, so two writers touching different keys almost never meet.
Get one from $arena->map($name). Every process can ask for the same name with the same arguments; the first creates it and the rest attach.
Keys and values are both arbitrary bytes. Neither is interpreted, and both may contain anything, including NUL. A key must not be empty; a value may be. A map made with serialise => 1 is the exception for values; see below.
It has a fixed size, and does not grow
The table is sized once and never rehashes. Growing a table that other processes are reading means moving entries they are part-way through looking for, and the only safe ways to do that are to stop every process or to keep two tables alive until the last reader has left. Neither belongs in something whose whole appeal is that a read is a hash and a compare.
So a full table refuses a new key and store returns 0. Watch used against capacity, and size the table for the worst case you will accept.
Overwriting a key that is already there always works, full table or not: it needs no new slot.
What deleting leaves behind
A deleted entry becomes a tombstone rather than an empty slot, and this is not an implementation detail a caller can ignore. An empty slot ends a search, so turning a deleted entry into one would make every key stored past it unreachable. The tombstone keeps the path open.
Tombstones are reused by the next key that lands on one, but they are never swept. A workload that deletes constantly will accumulate them and start refusing keys while used still looks low. tombstones is in the stats so that is a number rather than a mystery. If you are churning keys, size for used + tombstones, or use a fresh map.
Reading while somebody writes
A value can be replaced while another process is reading it. Each entry carries a version, so a reader that copied a value while it was being replaced notices and tries again.
A reader that keeps losing gives up after a bounded number of attempts. It reports the key as not readable, which fetch spells as an empty list, and counts a busy. A non-zero busy does not mean a key was missing: it means a fetch gave up on an entry that may well be there. It should be rare enough to be interesting.
Serialised values
my $map = $arena->map('routes', slots => 4096, serialise => 1);
$map->store($host, { upstream => $u, weight => 3 });
my ($route) = $map->fetch($host);
By default a value is bytes: a reference is stringified going in and comes out as HASH(0x...), and a string's UTF-8 flag does not survive. With serialise => 1 a value is any Perl structure, encoded through Struct::Codec on the way in and decoded on the way out, and what comes back is the same structure: strings stay strings and numbers stay numbers, the UTF-8 flag survives, a blessed referent is blessed into the same class, and references that were shared or cyclic are shared or cyclic again. Every process gets its own copy; nothing is read in place, which is what Shared::Arena::Frozen is for.
Keys are bytes either way. A value that cannot be encoded, a closure for one, croaks with the codec's reason and stores nothing. A value whose encoding does not fit a slot is refused exactly as an oversized byte value is, so store answers -1 for both. A corrupt slot makes fetch croak rather than hand back bytes as if they were a value.
incr croaks on a serialised map and counter answers undef: a counter is a raw word and cannot also be an encoded value. exists, delete and keys are unchanged.
The flag is part of the map's shape, kept in the shared header beside slot_size, because a map one process treats as encoded and another treats as bytes is two maps that disagree about every value. Every process must ask for the same setting, and one that asks for the other is refused as a different slot_size would be.
METHODS
store
my $rc = $map->store($key, $value);
my $rc = $map->store($key, $value, ttl => 300); # seconds
my $rc = $map->store($key, $value, ttl_ms => 5000); # milliseconds
1 when stored, 0 when the table is full, -1 when the key is empty or the pair does not fit a slot.
On a serialised map $value is any structure, and -1 also answers one whose encoding does not fit. A value that cannot be encoded croaks.
A ttl gives the entry a deadline. After it, the key reads as absent from every process - fetch returns an empty list and exists returns false - because the deadline is a wall-clock timestamp stored in the entry, not per-process state. Without one the entry lives until it is deleted or overwritten.
This is the difference between a map and a denylist, a nonce store, or a dedup window. Doing it by hand - storing an expiry beside the value and checking it on every read - works until the day a caller forgets the check, and a forgotten expiry check turns every ban permanent. Here the map keeps the deadline.
Expiry is lazy. A lapsed entry is not swept in the background; it is collected by the next fetch or exists that lands on it, which turns the slot into a tombstone the next insert can reuse. An entry nobody looks at again sits until an insert probes over it. stats reports expired, the running count of entries collected this way, so a table full of stale keys is visible rather than mysterious.
A re-store of a key sets a fresh deadline, so touching an entry renews it.
The clock is read only when a deadline is involved, so a map that never passes a ttl pays nothing for the feature.
fetch
my ($value) = $map->fetch($key);
The value, or an empty list when the key is not there. Not undef: undef is a value a caller may legitimately store, and a door that used it to mean absent could not tell the two apart. Use exists to ask the other question.
On a serialised map the structure comes back decoded, a fresh copy each call.
exists
if ($map->exists($key)) { ... }
delete
my $was_there = $map->delete($key);
incr
my $now = $map->incr($key);
my $now = $map->incr($key, $by);
Adds to a counter, creating it at $by when the key is absent, and returns the new value. $by may be negative. Returns undef when the table is full, or when the key holds something that is not a counter.
This is the one operation that does not go through the version at all: a counter is a single machine word, so the addition is one atomic instruction and cannot be seen half-done. Two hundred processes incrementing one key lose nothing, and a shared rate limit costs about what incrementing a variable costs.
A key holding a value that is not a counter is refused rather than reinterpreted. Storing a string and then counting on it is a bug, and quietly overwriting the string would hide both the bug and the string.
A counter whose TTL has lapsed is treated as gone: the next incr starts it fresh at $by rather than adding to the stale value. A window counter and its deadline stay consistent without the caller resetting anything.
Croaks on a serialised map.
counter
my $n = $map->counter($key);
A counter's value without changing it, or undef when the key is absent or holds something else. Always undef on a serialised map.
serialised
if ($map->serialised) { ... }
Whether this map was made with serialise => 1.
keys
my @keys = $map->keys;
Every live key, in the table's own order, which is neither insertion order nor sorted.
A snapshot, not a lock. Entries may be added or removed while this walks, so a key it returns may be gone by the time you use it, and one added behind the walk will not appear. It is for looking at a table, not for iterating one that is being written.
stats
my %s = $map->stats;
# used, capacity, tombstones, busy, full, expired
max_pair, capacity
my $bytes = $map->max_pair; # key + value that fits one slot
my $slots = $map->capacity;
SIZING A MAP
my $map = $arena->map('cache', slots => 4096, slot_size => 512);
my $map = $arena->map('routes', slots => 4096, serialise => 1);
slots is the number of entries, and there is no load factor to leave room for beyond your own: an open-addressed table slows down as it fills, so leave headroom rather than sizing it exactly. slot_size bounds a key and its value together; ask max_pair rather than working it out.
A map costs slots * slot_size bytes for its life, used or not.
SEE ALSO
Shared::Arena, Shared::Arena::Ring, Struct::Codec.
AUTHOR
LNATION, <email at lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under the Artistic License 2.0.