NAME

Data::Reservoir::Shared - shared-memory reservoir sampler (uniform stream sample)

SYNOPSIS

use Data::Reservoir::Shared;

# keep a uniform random sample of 100 items from an unbounded stream
my $rsv = Data::Reservoir::Shared->new(undef, 100);

$rsv->add($_) for @stream;      # feed items; each is kept or discarded

my @sample = $rsv->sample;      # up to 100 items, uniformly sampled
$rsv->count;                    # how many are held (min of seen, 100)
$rsv->seen;                     # total items observed

# share the reservoir across processes via a backing file
my $shared = Data::Reservoir::Shared->new("/tmp/sample.rsv", 100);

# weighted sampling: keep items with probability proportional to weight
my $wrsv = Data::Reservoir::Shared->new_weighted(undef, 100);
$wrsv->add($event, $weight) for @stream;   # heavier items kept more often

DESCRIPTION

A reservoir sampler in shared memory: it keeps a uniform random sample of k items drawn from a stream of unknown or unbounded length, in fixed memory, using Algorithm R. Feed it items one at a time with add; while fewer than k items have been seen every item is kept, and after that the i-th item replaces a uniformly random slot with probability k/i. At any point the k retained items are a uniform random sample of everything seen so far -- the standard way to sample a log stream, sample events for telemetry, or take a fair sample of a data set too large to hold.

Because the reservoir lives in a shared mapping, several processes feed and read one sample: any process that opens the same backing file, inherits the anonymous mapping across fork, or reopens a passed memfd contributes to and reads the same reservoir. The sampling RNG (a xorshift64) lives in the shared header and is advanced under the write lock, so concurrent producers sample one consistent reservoir. A write-preferring futex rwlock with dead-process recovery guards mutation.

Items are stored inline, truncated to item_size bytes (default 256): an item longer than item_size keeps only its first item_size bytes. Memory is about k * S bytes for the slots, where the per-slot stride S rounds 8 + item_size up to a multiple of 8, plus a fixed header. Items are handled by their byte content; wide-character strings (any codepoint above 255) cause a "Wide character" croak -- encode to bytes first. Linux-only. Requires 64-bit Perl.

Weighted sampling (A-Res)

A reservoir created with new_weighted keeps a weighted random sample: each item carries a positive weight and is retained with probability proportional to its weight rather than uniformly. This is the Efraimidis-Spirakis A-Res algorithm -- each observed item is assigned a key u ** (1/weight) (with u uniform in (0,1]) and the k items with the largest keys are kept, tracked in a shared min-heap so a heavier arrival can evict the current lightest member. With a single slot the probability of keeping item i is exactly w_i / sum(w). Feed weighted items with $rsv->add($item, $weight) (the weight must be a finite number greater than zero); everything else -- sample, count, get, clear, and cross-process sharing -- works identically. A weighted reservoir stores an extra k * 16 bytes for the heap and records its mode in the header, so a reopened segment stays weighted.

METHODS

Constructors

my $rsv = Data::Reservoir::Shared->new($path, $k, $item_size);
my $rsv = Data::Reservoir::Shared->new(undef, $k);              # anonymous, item_size 256
my $rsv = Data::Reservoir::Shared->new_memfd($name, $k, $item_size);
my $rsv = Data::Reservoir::Shared->new_from_fd($fd);

# weighted (A-Res) reservoir -- same arguments, weighted sampling
my $rsv = Data::Reservoir::Shared->new_weighted($path, $k, $item_size);
my $rsv = Data::Reservoir::Shared->new_weighted_memfd($name, $k, $item_size);

$k is the reservoir size (the number of items to retain, at least 1). $item_size is the maximum bytes stored per item (default 256; items are truncated to it). new and new_memfd croak on a size below 1 or an out-of-range $item_size. When reopening an existing file or memfd the stored geometry wins and the caller's arguments are ignored. An optional file mode may be passed as the last argument to new (e.g. 0660) for cross-user sharing; it defaults to 0600 (owner-only).

Sampling

my $kept  = $rsv->add($item);            # uniform: 1 if now stored, 0 if discarded
my $kept  = $rsv->add($item, $weight);    # weighted reservoir: weight must be > 0
my $n     = $rsv->add_many(\@items);              # uniform: array ref of items
my $n     = $rsv->add_many([[$item,$w], ...]);     # weighted: [item, weight] pairs
my @items = $rsv->sample;            # current sample (a list, up to k items)
my $it    = $rsv->get($i);           # the i-th retained item (0-based), or undef
$rsv->clear;                         # empty the reservoir (seen resets to 0)

add feeds one item and returns 1 if it is now stored in the reservoir or 0 if it was discarded. On a weighted reservoir add takes a second argument, the item's weight (a finite number greater than zero -- a missing, zero, negative, infinite, or NaN weight croaks). add_many feeds an array reference under a single write lock, returning how many of the batch were stored (an item that replaces an earlier sample counts as stored; use count for the number currently retained); for a weighted reservoir each element must be an [$item, $weight] pair. sample returns the retained items as a list (order is not meaningful); get returns a single retained item by index. clear empties the reservoir and resets the seen counter.

Introspection and RNG

$rsv->count;        # number of items currently held: min(seen, k)
$rsv->seen;         # total items observed
$rsv->capacity;     # k
$rsv->item_size;    # max bytes per item
$rsv->is_weighted;  # true for a weighted (A-Res) reservoir
$rsv->seed($n);     # set the RNG state (for reproducible sampling in tests)
$rsv->stats;        # { size, item_size, count, seen, ops, mmap_size, weighted }

seed sets the shared RNG state to a fixed value, making subsequent sampling deterministic -- useful for reproducible tests. By default the RNG is seeded from process and clock entropy at creation, so different runs draw different samples.

Lifecycle

$rsv->path; $rsv->memfd; $rsv->sync; $rsv->unlink;

sync flushes the mapping to its backing store (a no-op for anonymous and memfd reservoirs); unlink removes the backing file (also callable as Class->unlink($path)); path returns the backing path (undef for anonymous, memfd, or fd-reopened reservoirs) and memfd the backing descriptor.

SHARING ACROSS PROCESSES

The reservoir lives in a shared mapping, shared the same three ways as the rest of the family: a backing file, an anonymous mapping inherited across fork, or a memfd passed to an unrelated process and reopened with new_from_fd($fd). Every process's add feeds the one shared reservoir, and the shared RNG keeps the sampling probabilities consistent across producers.

SECURITY

Backing files are created with mode 0600 (owner-only) by default; pass an explicit octal mode (e.g. 0660) as the last argument to new for cross-user sharing. The file is opened with O_NOFOLLOW and O_EXCL, and the header is validated on attach. Any process granted write access is trusted not to corrupt the mapping.

CRASH SAFETY

Mutation is guarded by a futex-based write-preferring rwlock with PID-encoded ownership and dead-owner recovery. Each add is a short bounded update, so a crash leaves the reservoir consistent up to the last completed operation. Limitation: PID reuse is not detected (very unlikely in practice).

Reader-slot exhaustion (slotless readers): dead-process recovery attributes a crashed lock holder's contribution through its reader-slot. The slot table holds 1024 entries (one per concurrent reader process). If more than that many reader processes share one mapping at once, a reader that cannot claim a slot proceeds "slotless" -- it still takes the read lock but leaves no per-process record. If such a slotless reader is then killed while holding the read lock, its share of the lock cannot be attributed to a dead process, so writer recovery cannot reclaim it and writers may block until the mapping is recreated. Reaching this needs more than 1024 concurrent reader processes on one mapping plus a crash in the brief read-lock window; the dead-process slot reclaim keeps the table from filling with stale entries, so in practice it is very unlikely.

SEE ALSO

Data::HyperLogLog::Shared (cardinality estimation), Data::CountMinSketch::Shared (frequency estimation), and the rest of the Data::*::Shared family.

AUTHOR

vividsnow

LICENSE

This is free software; you can redistribute it and/or modify it under the same terms as Perl itself.