NAME Data::TopK::Shared - shared-memory top-k heavy hitters (Space-Saving, optional time decay) SYNOPSIS use Data::TopK::Shared; # track the 100 most frequent keys of a stream my $tk = Data::TopK::Shared->new(undef, 100); $tk->add($_) for @stream; # feed keys one at a time for my $h (@{ [ $tk->top(10) ] }) { # the 10 heaviest, count-descending printf "%s: ~%d (+/- %d)\n", $h->{key}, $h->{count}, $h->{error}; } my $est = $tk->estimate("popular"); # estimated count of one key (0 if untracked) # share the summary across processes via a backing file my $shared = Data::TopK::Shared->new("/tmp/hitters.tk", 100); # time-decayed: recent hits outweigh old ones (half-life 3600 time units) my $decayed = Data::TopK::Shared->new_decayed(undef, 100, 256, 3600); $decayed->add($key, $epoch_seconds); # optional timestamp; else a per-add tick $decayed->top(10); # the heaviest *recent* hitters DESCRIPTION A top-k heavy hitters summary in shared memory: with a fixed set of "capacity" counters it tracks the most frequent keys of a stream and estimates each one's frequency, using the Space-Saving algorithm, in memory that does not grow with the stream. This is the standard way to find the top talkers, hot URLs, busiest source IPs, or most-frequent search terms without storing a per-key counter for every distinct key seen. Feed keys with "add". While a counter is free a new key claims it; once all "capacity" counters are in use, a new key evicts the smallest counter and takes over its count as an over-estimate bound. Consequently each monitored key carries a "count" (an upper bound) and an "error" (the maximum over-estimate), so its true frequency lies in "[count - error, count]". Any key whose true frequency exceeds "total_observations / capacity" is guaranteed to be retained -- the genuine heavy hitters never fall out. Because the summary lives in a shared mapping, several processes feed one top-k: 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 counters. A write-preferring futex rwlock with dead-process recovery guards mutation. Keys are handled by their byte content, truncated to "key_size" bytes (default 256): two keys sharing a "key_size"-byte prefix are counted as one. Wide-character strings (any codepoint above 255) cause a "Wide character" croak -- encode to bytes first. Linux-only. Requires 64-bit Perl. Time decay (recent hitters) A summary created with "new_decayed($path, $capacity, $key_size, $half_life)" keeps a time-decayed top-k: an observation's contribution halves every $half_life time units, so recent activity dominates and stale heavy hitters fade out. It uses forward decay (Efraimidis / Cormode): counts are stored as decayed weights so the Space-Saving min-heap stays consistent, with an occasional internal landmark rescale to keep the weights bounded (no unbounded growth). Feed it with "$tk->add($key, $timestamp)" -- $timestamp is any non-decreasing number in your own units (epoch seconds, milliseconds, an event index); if you omit it, time advances one tick per "add". A past (smaller) timestamp does not rewind the clock. "estimate", "error", and "top" then report decayed counts (floating point) as of the latest time seen; everything else -- eviction bounds, cross-process sharing, "clear" -- works the same. A plain (non-decayed) summary is unchanged and on-disk compatible. METHODS Constructors my $tk = Data::TopK::Shared->new($path, $capacity, $key_size, $mode); my $tk = Data::TopK::Shared->new(undef, $capacity); # anonymous, key_size 256 my $tk = Data::TopK::Shared->new_memfd($name, $capacity, $key_size); my $tk = Data::TopK::Shared->new_from_fd($fd); # time-decayed (forward decay) -- extra half_life argument my $tk = Data::TopK::Shared->new_decayed($path, $capacity, $key_size, $half_life); my $tk = Data::TopK::Shared->new_decayed_memfd($name, $capacity, $key_size, $half_life); "new_decayed" / "new_decayed_memfd" take a final $half_life (a finite number greater than 0, in whatever time units you feed to "add"): a contribution's weight halves every $half_life units. They croak on a non-positive or non-finite half-life. $capacity is the number of counters (the maximum number of keys tracked at once, at least 1, up to 2^24) -- pick it a few times larger than the number of heavy hitters you want to reliably capture. $key_size is the maximum bytes stored per key (default 256; longer keys are truncated). Memory is roughly "capacity * (40 + key_size)" bytes plus a fixed header. "new" and "new_memfd" croak on a capacity below 1 or above 2^24, or a $key_size outside 1..4096. 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). Feeding and querying my $count = $tk->add($key); # observe one key; returns its new estimated count my $count = $tk->add($key, $t); # decayed mode: observe at time $t (optional) $tk->observe($key); # alias for add my $n = $tk->add_many(\@keys); # observe a batch under one lock; returns the batch size my @top = $tk->top($k); # the $k heaviest as hashrefs, count-descending my @all = $tk->top; # all tracked keys (k defaults to every counter) my $est = $tk->estimate($key); # estimated count of one key, 0 if untracked my $err = $tk->error($key); # the over-estimate bound for that key (0 if exact/untracked) $tk->clear; # forget everything (counters and totals reset) "add" observes one key and returns its estimated count after the observation. "add_many" feeds an array reference under a single write lock. "top" returns a list of hash references "{ key => $bytes, count => $n, error => $e }" sorted by "count" descending (ties broken by tighter "error" first); with no argument, or an argument larger than the number tracked, it returns every tracked key. "estimate" returns the upper-bound count for a single key (0 if it is not currently tracked); "error" returns that key's over-estimate bound, so its true count lies in "[estimate - error, estimate]". To read a key's count and error as a consistent pair under one lock, use the hashref "top" returns for it. Introspection $tk->capacity; # number of counters $tk->key_size; # max bytes stored per key $tk->tracked; # keys currently tracked: min(distinct seen, capacity) $tk->seen; # total observations fed $tk->is_decayed; # true for a time-decayed summary $tk->half_life; # the decay half-life (0 for a plain summary) $tk->stats; # { capacity, key_size, tracked, seen, ops, mmap_size, decayed[, half_life] } "is_decayed" reports whether the summary is time-decayed; "half_life" returns its half-life (0 for a plain summary). In decayed mode "estimate", "error", and the "count"/"error" fields of "top" are decayed counts (floating point) as of the latest observed time; "stats" gains "decayed" (and "half_life" when decayed). Lifecycle $tk->path; $tk->memfd; $tk->sync; $tk->unlink; "sync" flushes the mapping to its backing store (a no-op for anonymous and memfd summaries); "unlink" removes the backing file (also callable as "Class->unlink($path)"); "path" returns the backing path ("undef" for anonymous, memfd, or fd-reopened summaries) and "memfd" the backing descriptor. ACCURACY Space-Saving gives one-sided guarantees. Every key's "count" is an upper bound on its true frequency, and its true frequency is at least "count - error". Any key whose true frequency exceeds "seen / capacity" is guaranteed to be among the tracked keys, so with "capacity" comfortably larger than the number of genuine heavy hitters, the top of the list is exact and only the long tail is approximate. Increasing "capacity" tightens the error on the marginal keys. SHARING ACROSS PROCESSES The summary 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 summary, so a fleet of workers can each process part of a stream into a single top-k. 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 summary 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::CountMinSketch::Shared (per-key frequency estimation), Data::Reservoir::Shared (uniform sampling), 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.