NAME

Shared::Arena::Frozen - one structure, published once and read in place by every process

VERSION

Version 0.03

SYNOPSIS

use Shared::Arena;

my $arena = Shared::Arena->create(size => 8 * 1024 * 1024);
my $conf  = $arena->frozen('config', size => 256 * 1024);

# whoever has the data publishes it
$conf->publish({
    db       => { host => 'localhost', port => 5432 },
    features => { search => 1, billing => 0 },
    routes   => [ '/', '/login', '/admin' ],
});

# every worker reads it without rebuilding any of it
my $port = $conf->get('db.port');
my $on   = $conf->get('features.search');

# several reads that must all come from the same publish
my $view = $conf->view or return;
my ($host, $p) = ($view->get('db.host'), $view->get('db.port'));

DESCRIPTION

Every other tenant in this dist stores bytes. A key and a value are opaque strings, so a nested structure has to be flattened on the way in and rebuilt on the way out, and the rebuilding happens on every read.

This one does not rebuild. It stores a Frozen block: a flat structure, addressed by offset, whose fields are read where they lie. Looking up one key does not construct the other ten thousand, and a hundred workers reading the same configuration read the same bytes rather than each holding a copy.

That makes it a different shape of thing from Shared::Arena::Map, and the two are worth different jobs:

  • A map is many small values that change constantly.

  • A frozen block is one large value that changes rarely and is read constantly.

Configuration, a routing table, feature flags, a compiled ruleset, a lookup table built at boot. The block is built once by whoever has the data and read by every worker for the life of the process.

Reading one field, against the same structure serialized into a Shared::Arena::Map and rebuilt per read, on an M-series Mac:

structure      serialized     get    view per read   view held
10 keys          2,205 ns   73 ns           214 ns       74 ns
200 keys        29,675 ns   74 ns           211 ns       73 ns
2000 keys      317,568 ns   79 ns           221 ns       80 ns

Every column but the first barely moves, and that is the entire point rather than a detail of the benchmark: the cost of reading one field does not depend on how big the structure is, because nothing is rebuilt to get at it. The first column is proportional to the whole structure every single time.

get is what a request handler should use: it reads whatever is live now, with nothing to take or drop. A view taken per read pays mostly for making and destroying the view. A held view reads as fast as get and is for several reads that must all come from the same publish. See "get" and "view".

A block cannot be edited, so publishing replaces it

A Frozen block is addressed by offset throughout, so changing one string moves everything after it. There is no editing a value in place, and a reader halfway through such an edit would follow an offset into the middle of something else.

So publish never writes where anybody is reading. The region holds several slots, and a publish writes the new block into the slot furthest from use and then points readers at it in one store. No reader waits for a publisher, no publisher waits for a reader, and no reader ever sees half a block.

What a view borrows, and what that costs

view does not copy. It hands back a reader pointing at the bytes where they lie in the arena, which is the whole reason this beats sending the same structure down a pipe: the cost of a view does not grow with the size of the block.

The bytes are not the view's to keep. After slots further publishes, the slot a view is reading is reused, and the view then reads a different block: still structurally valid, still inside its own bounds, but not the one it was opened on. Ask $view->fresh to find out.

The rule that follows is short:

For a lookup, use "get" and hold nothing. When several reads must agree, take a view, read it, drop it. A view cached in a global and held across a configuration reload is the one thing this cannot make safe.

METHODS

publish

my $gen = $conf->publish($data);
my $gen = $conf->publish($data, lossy_nv => 1);

Freezes an ordinary Perl structure and publishes it. Returns the new generation, a number that increases by one per publish and is never reused, or undef when the frozen block is larger than the size the region was carved with.

lossy_nv passes through to Frozen, for a caller who would rather have floating point values stored compactly than exactly.

Publishing takes a lock, which is the right way round: a publish copies a whole block and is already thousands of times the cost of the atomic that reads one, and two processes publishing at once disagree about what the configuration is, so one of them should win outright rather than the two interleaving. Reads take no lock at all.

publish_bytes

my $gen = $conf->publish_bytes($block);

Publishes bytes that are already a Frozen block, which is what a process that received one over a socket has: no freezing and no round trip through Perl data.

The bytes are checked before they are published, structurally and not just for a header. They are about to become every other process's idea of the configuration, and the cheapest moment to discover they are not a block is before publishing rather than during somebody else's read. Croaks if they are not one.

get

my $port = $conf->get('db.port');
my $host = $conf->get('db/host', '/');

The value at a dotted path in whatever is published now, or an empty list when nothing is published or the path does not resolve. A second argument is the separator, one character, for keys with a dot in them.

This is the method for a request handler. It keeps one reader over the live block and takes a new one only after a publish, so there is no view to take and drop, and every call sees the latest publish. Two calls may therefore read two different blocks if a publish lands between them; take a "view" when several reads must agree.

find

my $v = $conf->find('a.key.with.dots');

One key at the top level of whatever is published now, with no path splitting. An empty list when it is not there.

exists

if ($conf->exists('features.search')) { ... }

Whether a path resolves in whatever is published now, without building the value. False when nothing has been published.

view

my $view = $conf->view or return;   # nothing published yet

A Shared::Arena::Frozen::View of whatever is published now, or an empty list when nothing has been published. Not undef for absent, for the reason every other door in this dist gives an empty list: so that or return and a defined test mean the same thing.

generation

my $n = $conf->generation;

How many times anything has been published, which is also the generation of the block a view would open now. Zero before the first publish. Cheap: one atomic load, so a worker may ask on every request whether its configuration has moved.

max_block

my $bytes = $conf->max_block;

The largest block this region will carry, which is the size it was carved with. A runtime accessor rather than a constant a caller compiles in, for the same reason the ring's max_record is.

stats

my %s = $conf->stats;

generation, published, refused, slots, max_block, and bytes for the block that is live now.

refused counts publishes that did not fit. A non-zero refused means somewhere a process thinks it published a configuration and did not, so it belongs on a status page.

SIZING ONE

$arena->frozen($name, size => $bytes, slots => 4);

size is the largest block, and the region costs size * slots, because the point of the slots is that a publish never writes where somebody is reading. slots is between 2 and 16 and defaults to 4.

More slots do not make publishing faster. What they buy is how many publishes a held view survives, so raise it only if views are held longer than they should be, and prefer fixing that.

CAVEATS

A view goes stale after slots publishes. It does not become unsafe to read, and it will not crash: the reader stays inside the bytes it was given. It becomes a reader of a newer block than it asked for. $view->fresh answers, and a view taken per request never has the problem.

Publishing is not for hot data. It copies the whole block and takes a lock. It is for configuration that changes on deploy, not for something that changes per request. That is what the ring and the map are for.

The block is trusted. publish_bytes verifies structure, which is what makes bytes from elsewhere safe to accept. It does not verify the checksum: that is a separate pass and Frozen's own verify is where it lives.

SEE ALSO

Shared::Arena, Shared::Arena::Frozen::View, Frozen, Shared::Arena::Map

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.