NAME

Data::Shared::Cookbook - Leaf operations and application recipes

DESCRIPTION

Run each recipe on a fresh root with this preamble:

use Data::Shared qw(shared_get shared_set shared_delete shared_exists
                    shared_incr shared_call shared_push shared_pop
                    shared_compile sharedc_get sharedc_incr);
use File::Temp ();
my $dir = File::Temp::tempdir(CLEANUP => 1);
my $root = Data::Shared->new($dir);   # file-backed
# or: my $root = Data::Shared->new;   # anonymous

Other processes reopen a file-backed root with Data::Shared->open($dir) ("PROCESSES"); see "FREEZE AND SHIP" for sealed stores. A recipe that reopens a path the facade supplied needs the file-backed root -- "ReqRep (request and response)" and "FREEZE AND SHIP" -- since an anonymous mount leaves no file under $dir.

CHOOSING A STRUCTURE

  • An exact count per key: hashmap:si with shared_incr ("Rate limiter over a fixed window").

  • A cache or key-value store: hashmap:ii, hashmap:ss and the other HashMap variants.

  • Counters by integer index: buffer:i64.

  • Work for other processes: queue:int or queue:str, consumed with pop_wait ("Workers forked from one root"); heap when jobs have priorities.

  • The latest values of a stream: ringbuffer; an append-only record: log.

  • Seen before?: bloomfilter, or cuckoofilter when entries must also be removed.

  • Distinct count, frequent items, percentiles: hyperloglog, countminsketch and topk, ddsketch or histogram.

  • A leaderboard: sortedset, or sortedset:strings for string members.

  • Prefix or route lookup: radixtree; string-to-id mapping: intern; a fixed set shipped read-only: perfecthash.

  • Delays and timeouts: timingwheel; bounded concurrency: sync:semaphore; request and reply between processes: reqrep.

  • One structure per user or tenant: a composite ("COMPOSITES (DEEP PATHS)").

MAP-LIKE

HashMap (cache with variants)

$root->mount('cache', 'hashmap:ii', undef, 100_000);
$root->mount('names', 'hashmap:ss', undef, 100_000);
shared_set($root, '/cache/42', 100);       # 1
shared_get($root, '/cache/42');            # 100
shared_incr($root, '/cache/42');           # 101
shared_incr($root, '/cache/42', 10);       # 111
shared_call($root, '/cache', 'cas', 42, 111, 200);   # 1: 100+1+10 -> 200
shared_delete($root, '/cache/42');         # 1
# The :ss variant takes string keys and values, for a session or a token.
shared_set($root, '/names/t-abc', '{"uid":42}');     # 1
shared_get($root, '/names/t-abc');                   # '{"uid":42}'
shared_delete($root, '/names/t-abc');                # 1

Buffer (typed shared array)

$root->mount('counters', 'buffer:i64', undef, 1024);   # file auto-supplied
shared_set($root, '/counters/0', 42);       # 1
shared_get($root, '/counters/0');            # 42
shared_incr($root, '/counters/0');           # 43

File-backed roots supply Buffer's path; anonymous roots use its new_anon fallback.

RadixTree (route table)

$root->mount('routes', 'radixtree', undef, 65536, 1 << 20);
shared_set($root, '/routes/10.0.0.0/8', 100);   # 1; remainder is joined
shared_get($root, '/routes/10.0.0.0/8');        # 100
shared_set($root, '/routes/10.0', 200);        # 1
shared_call($root, '/routes', 'longest_prefix', '10.0.0.5');   # 200: byte prefix, not CIDR

Intern (reversible string ids)

$root->mount('ids', 'intern', undef, 1_000_000, 32 << 20);
my $id = shared_set($root, '/ids/alice');       # intern, returns id
shared_get($root, '/ids/alice');                # $id
shared_call($root, '/ids', 'string', $id);      # 'alice'

SortedSet Strings (string-keyed leaderboard)

$root->mount('board', 'sortedset:strings', { max => 100_000 });
shared_set($root, '/board/alice', 1500);         # 1: new member
shared_get($root, '/board/alice');              # 1500
my @top = shared_call($root, '/board', 'rev_range_by_rank', 0, 9); # ('alice')

Pool (slot allocator)

$root->mount('slots', 'pool:i64', undef, 1000);
my $idx = shared_call($root, '/slots', 'alloc'); # allocated slot index
shared_set($root, "/slots/$idx", 42);           # undef
shared_get($root, "/slots/$idx");               # 42
shared_delete($root, "/slots/$idx");            # 1: freed

PerfectHash (immutable exact set)

$root->mount('words', 'perfecthash', { build_str => ['apple', 'banana'], path => "$dir/words.phs" });
shared_exists($root, '/words/banana');          # 1
shared_exists($root, '/words/cherry');          # 0
shared_call($root, '/words', 'count');          # 2

SET-LIKE

BloomFilter (probable membership)

$root->mount('seen', 'bloomfilter', undef, 1_000_000, 0.01);
shared_set($root, '/seen/event-42');            # 1: probably new
shared_exists($root, '/seen/event-42');         # 1: probably present

CuckooFilter (deletable membership)

$root->mount('edge', 'cuckoofilter', undef, 1_000_000);
shared_set($root, '/edge/event-42');            # 1: inserted
shared_exists($root, '/edge/event-42');         # 1: probably present
shared_delete($root, '/edge/event-42');         # 1: removed

CountingBloomFilter (occurrence estimates)

$root->mount('multi', 'countingbloomfilter', undef, 1_000_000, 0.01);
shared_set($root, '/multi/event-42');           # 1: probably new
shared_get($root, '/multi/event-42');           # 1: count estimate
shared_delete($root, '/multi/event-42');        # 1: decremented

BitSet (dense flags)

$root->mount('bits', 'bitset', undef, 256);
shared_set($root, '/bits/10', 1);               # 0: previous bit
shared_get($root, '/bits/10');                  # 1
shared_set($root, '/bits/10', 0);               # 1: previous bit

RoaringBitmap (sparse integer membership)

$root->mount('members', 'roaringbitmap', undef, 256);
shared_set($root, '/members/70000', 1);         # 1: newly added
shared_exists($root, '/members/70000');         # 1
shared_call($root, '/members', 'cardinality');  # 1

DisjointSet (connected groups)

$root->mount('ds', 'disjointset', undef, 1000);
shared_set($root, '/ds/0', 1);                  # 1: merged 0 and 1
shared_call($root, '/ds', 'connected', 0, 1);   # 1
shared_call($root, '/ds', 'set_size', 0);       # 2

SortedSet (integer members)

$root->mount('scores', 'sortedset', undef, 1000);
shared_set($root, '/scores/42', 1500);          # 1: new member
shared_set($root, '/scores/7', 900);            # 1
shared_get($root, '/scores/42');                # 1500
shared_call($root, '/scores', 'rank', 42);      # 1: lowest score first
shared_call($root, '/scores', 'rev_rank', 42);  # 0: highest first, for a leaderboard
my @top = shared_call($root, '/scores', 'rev_range_by_rank', 0, 9);  # (42, 7)

QUEUE-LIKE

Queue (first in, first out)

$root->mount('jobs', 'queue:int', undef, 1024);
shared_push($root, '/jobs', 42);                # 1
shared_pop($root, '/jobs');                     # 42
shared_call($root, '/jobs', 'pop_wait', 0.01);  # undef: empty after timeout

Stack (last in, first out)

$root->mount('stack', 'stack:str', undef, 50, 256);
shared_push($root, '/stack', 'hello');          # 1
shared_pop($root, '/stack');                    # 'hello'

Deque (both ends)

$root->mount('dq', 'deque:int', undef, 1024);
shared_push($root, '/dq', 42);                 # 1: pushed at back
shared_call($root, '/dq', 'push_front', 1);    # 1
shared_pop($root, '/dq');                      # 1: popped from front

RingBuffer (recent values)

$root->mount('rb', 'ringbuffer:int', undef, 16);
my $seq = shared_push($root, '/rb', 42);        # sequence number 0
shared_get($root, "/rb/$seq");                 # 42
shared_get($root, '/rb');                      # 42: the latest

Heap (priority queue)

$root->mount('tasks', 'heap', undef, 1000);
shared_push($root, '/tasks', 3, 300);           # 1: priority, value
shared_push($root, '/tasks', 1, 100);           # 1
my ($pri, $val) = shared_pop($root, '/tasks'); # (1, 100): lowest priority first

Log (append-only records)

$root->mount('audit', 'log', undef, 1_000_000);
shared_push($root, '/audit', 'first entry');    # offset 0
shared_call($root, '/audit', 'entry_count');    # 1

PubSub (future messages)

$root->mount('events', 'pubsub:int', undef, 1024);
my $sub = shared_call($root, '/events', 'subscribe'); # subscriber handle
shared_push($root, '/events', 42);              # 1: published
$sub->poll;                                    # 42

INDEXED

NDArray (multidimensional values)

$root->mount('lat', 'ndarray', undef, 'f64', 2, 3);
shared_set($root, '/lat/0/1', 1.5);             # undef
shared_get($root, '/lat/0/1');                  # 1.5
shared_call($root, '/lat', 'sum');              # 1.5

Fenwick (prefix totals)

$root->mount('counts', 'fenwick', undef, 1_000_000);
shared_incr($root, '/counts/5', 3);             # undef: update(5, 3)
shared_get($root, '/counts/5');                 # 3
shared_call($root, '/counts', 'prefix', 9);     # 3

Fenwick2D (rectangle totals)

$root->mount('grid', 'fenwick2d', undef, 24, 7);
shared_set($root, '/grid/9/1', 100);            # 0: previous cell value
shared_get($root, '/grid/9/1');                 # 100
shared_call($root, '/grid', 'rect', 8, 1, 12, 5); # 100

SegmentTree (range aggregates)

$root->mount('arr', 'segmenttree', undef, 1000);
shared_set($root, '/arr/10', 42);               # undef
shared_incr($root, '/arr/10', 5);               # 47
shared_call($root, '/arr', 'sum', 0, 99);       # 47

KDTree (nearest point)

$root->mount('cities', 'kdtree', undef, 2, 100_000);
shared_call($root, '/cities', 'add', [10.5, 20.5], 42); # insertion index 0
shared_call($root, '/cities', 'nearest', [10.0, 20.0]); # { id => 42, dist => ... }

IntervalTree (overlapping bookings)

$root->mount('bookings', 'intervaltree', undef, 100_000);
shared_call($root, '/bookings', 'add', 10, 20, 7); # insertion index 0
my @at = shared_call($root, '/bookings', 'stab', 15); # ({ lo => 10, hi => 20, id => 7 })

SpatialHash (nearby points)

$root->mount('geo', 'spatialhash', undef, 100_000, 0, 1.0);
shared_call($root, '/geo', 'insert', 10.5, 20.5, 42); # insertion index 0
my @near = shared_call($root, '/geo', 'query_radius', 10, 20, 5); # (42): payload ids

Graph (node data)

$root->mount('net', 'graph', undef, 1000, 10_000);
my $id = shared_call($root, '/net', 'add_node', 42);    # returns the id
shared_get($root, "/net/$id");                          # 42
shared_exists($root, "/net/$id");                       # 1

SKETCHES AND STATS

Keyed sketches accept generic verbs; the rest are call-driven.

CountMinSketch (frequency estimates)

$root->mount('freq', 'countminsketch', undef, 0.001, 0.001);
shared_set($root, '/freq/alice', 5);            # 5: new estimate
shared_get($root, '/freq/alice');               # 5

TopK (heavy hitters)

$root->mount('hitters', 'topk', undef, 100);
shared_set($root, '/hitters/popular') for 1 .. 3;   # the value is ignored
shared_set($root, '/hitters/rare');                 # 1
shared_get($root, '/hitters/popular');              # 3
my @top = shared_call($root, '/hitters', 'top', 10);
$top[0]{key};                                       # 'popular': the heavy hitter ranks first

HyperLogLog (distinct visitors)

$root->mount('visitors', 'hyperloglog', undef, 14);
shared_call($root, '/visitors', 'add', 'alice'); # 1: a register changed
shared_call($root, '/visitors', 'count');        # approximately 1

DDSketch (latency quantiles)

$root->mount('latency', 'ddsketch', undef, 0.01);   # 1% relative error
shared_call($root, '/latency', 'add', $_) for 10, 20, 30, 40, 50;
shared_call($root, '/latency', 'count');          # 5
shared_call($root, '/latency', 'quantile', 0.5);  # 30.3: the error is the 1%
shared_call($root, '/latency', 'quantile', 1);    # 49.9, not 50, for the same reason

Histogram (latency percentiles)

$root->mount('hist', 'histogram', undef, 1, 3_600_000_000, 3);
shared_call($root, '/hist', 'record', $_) for 100, 110, 120, 130, 9000;
shared_call($root, '/hist', 'total_count');            # 5
shared_call($root, '/hist', 'value_at_percentile', 50); # 120
shared_call($root, '/hist', 'value_at_percentile', 99); # 9007: three significant figures

Reservoir (bounded stream sample)

$root->mount('sample', 'reservoir', undef, 100);
shared_call($root, '/sample', 'add', $_) for 1 .. 1000; # each returns 1 if retained, else 0
my @sample = shared_call($root, '/sample', 'sample'); # 100 sampled integers
shared_call($root, '/sample', 'seen');          # 1000

MinHash (set similarity)

$root->mount('setsim', 'minhash', undef, 256);
$root->mount('other', 'minhash', undef, 256);
shared_call($root, $_, 'add', 'element') for '/setsim', '/other'; # each returns 1
shared_call($root, '/setsim', 'similarity', $root->handle('/other')); # 1: identical sets

SYNC AND TIMING (call-only)

Semaphore (bounded concurrency)

$root->mount('sem', 'sync:semaphore', undef, 4);
shared_call($root, '/sem', 'acquire');           # 1: acquired
shared_call($root, '/sem', 'release');           # undef

TimingWheel and HierTimingWheel (delays)

One wheel for short delays, a hierarchy of them for long ones; the same two calls drive both.

$root->mount('timers', 'timingwheel', undef, 512, 10_000);
my $id = shared_call($root, '/timers', 'add', 30, 42); # timer id
my @due = shared_call($root, '/timers', 'advance', 30); # (42)

$root->mount('htimers', 'hiertimingwheel', undef, 256, 4, 100_000);
my $hid = shared_call($root, '/htimers', 'add', 5000, 42); # timer id
my @hdue = shared_call($root, '/htimers', 'advance', 5000); # (42)

ReqRep (request and response)

The client sends asynchronously so this example can serve and reply in one process.

$root->mount('rpc', 'reqrep:int', undef, 1024, 64);
my $cli = Data::ReqRep::Shared::Int::Client->new($root->mount_info('rpc')->{file});
my $ticket = $cli->send(42);                    # request id; undef if full
my ($req, $id) = shared_call($root, '/rpc', 'recv'); # (42, $ticket)
shared_call($root, '/rpc', 'reply', $id, $req + 1); # 1
$cli->get($ticket);                             # 43

COMPOSITES (DEEP PATHS)

Per-user filters

$root->mount('users', ['hashmap:ss', 10_000, ['bloomfilter', 10_000, 0.01]]);
shared_set($root, '/users/alice/event-42', 1);   # 1
shared_exists($root, '/users/alice/event-42');   # 1
$root->children('/users');                       # ('alice') in list context
$root->node('/users/alice')->call('count');      # approximately 1
shared_delete($root, '/users/alice');            # 1: whole subtree

Scores with per-member filters

# A score written through the path reserves a child, so max_children caps
# those members too: past it, a new score fails however large max is.
$root->mount('leaders', ['sortedset:strings', { max => 10_000 }, ['bloomfilter', 10_000, 0.01]]);
shared_set($root, '/leaders/alice', 1500);        # 1: new member
shared_set($root, '/leaders/alice/event-9', 1);   # 1: filter insertion

Per-user queues

$root->mount('queues', ['hashmap:ss', 1000, ['queue:int', 1024]]);
$root->push('/queues/alice', 42);                 # 1
$root->pop('/queues/alice');                      # 42

Budget and open-child cache

# The budget is fixed when the composite is created, and each child takes
# a parent entry: raise both together.
$root->mount('users', { spec => 'hashmap:ss', args => [100_000],
    child => { spec => 'bloomfilter', args => [10_000, 0.01] },
    max_children => 100_000, cache_size => 64 });
shared_set($root, '/users/alice/event-42', 1);   # 1
$root->children('/users');                       # ('alice') in list context

Parents must be addressable (hashmap, sortedset, kdtree/graph by id); bloom filters, sketches, queues and sync mounts are leaves. See the DEEP PATHS section of Data::Shared.

PROCESSES

An anonymous root is shared with every process forked after its mounts and disappears with the last of them. A file-backed root is found again by its directory, from any process, until unlink_all removes it.

Workers forked from one root

my $pool = Data::Shared->new;                    # anonymous: shared with forks
$pool->mount('jobs', 'queue:int', undef, 1024);
$pool->mount('done', 'hashmap:ii', undef, 1024);
shared_push($pool, '/jobs', $_) for 1 .. 100;
for (1 .. 4) {
    my $pid = fork // die "fork: $!";
    next if $pid;
    while (defined(my $job = shared_call($pool, '/jobs', 'pop_wait', 0.1))) {
        shared_set($pool, "/done/$job", $job * $job);
    }
    exit;
}
1 while wait > 0;
shared_call($pool, '/done', 'size');             # 100
shared_get($pool, '/done/7');                    # 49

A worker here stops once the queue stays empty for the wait; a long-running one loops on its own stop condition instead.

Separate processes over one directory

# The coordinator, once, at startup.
my $app = Data::Shared->new($dir);
$app->mount('totals', 'hashmap:si', undef, 1000) unless $app->mount_info('totals');
# Any worker process, at any time.
my $worker = Data::Shared->open($dir);
shared_incr($worker, '/totals/requests');        # 1
# At shutdown, when nothing should outlive the service.
$app->unlink_all;

Let one coordinator mount; workers only open. A root opened before another process changed the topology gets stale manifest revision from its next mount and has to reopen.

BUILDING APPLICATIONS

Rate limiter over a fixed window

HashMap:SI keeps an exact, atomic count per client and time bucket.

$root->mount('rate', 'hashmap:si', undef, 1000);
my $window = int(time / 60);
my $used = shared_incr($root, "/rate/$window:alice"); # 1 on the first request
my $allowed = $used <= 100;                       # 1: allow this request

Run the increment for every request. A coordinator must delete expired bucket keys; there is no automatic expiry. Fixed windows permit bursts at boundaries.

Hot loops

A string path is parsed and resolved on every call. Compile it once, or take the native handle when the loop stays on one structure:

$root->mount('cache', 'hashmap:ii', undef, 1000);
my $cp = shared_compile('/cache/42');
sharedc_incr($root, $cp) for 1 .. 1000;
sharedc_get($root, $cp);                          # 1000
my $cache = $root->handle('/cache');              # the native HashMap
$cache->incr(42) for 1 .. 1000;
$cache->get(42);                                  # 2000

Per-tenant namespace

A HashMap parent gives each tenant an independent HashMap:SI of exact counters.

$root->mount('tenants', ['hashmap:ss', 100, ['hashmap:si', 1000],
    { max_children => 100, cache_size => 8 }]);
shared_incr($root, '/tenants/acme/hits');         # 1
shared_incr($root, '/tenants/other/hits', 5);     # 5
shared_get($root, '/tenants/acme/hits');          # 1: separate from other

FREEZE AND SHIP

$root->mount('cache', 'hashmap:ii', undef, 1000);
shared_set($root, '/cache/42', 100);              # 1
$root->freeze_all;                               # 1: newly sealed handle
my $ro = Data::Shared->open_readonly($dir, strict => 1);
shared_get($ro, '/cache/42');                     # 100: lock-free read

Shipping requires every mount the manifest records to support frozen read-only access or be immutable (perfecthash); an attached handle is not shipped. The 17 unsupported families listed in Data::Shared (buffer, for one) and the sortedset:strings variant need writable mappings even for reads, so they can be read in place but cannot open from a write-protected copy.

Freezing is one-way, so freeze_all refuses a store holding an unsupported mount, at any composite level, before sealing anything, and names the first one. On the consumer side open_readonly($dir, strict => 1) confirms the result, naming the first unsupported mount and class, including lazy composite leaves; frozen is only a boolean, and plain open_readonly may defer failure until a child is opened. Use Data::Shared->open_sealed($dir) for teardown.