NAME
Punk::Observe::Store - sealing, and the read path over what is sealed
SYNOPSIS
use Punk::Observe::Store;
my $store = Punk::Observe::Store->new(
dir => '/var/lib/observe', tenant => 'acme');
$store->seal; # this worker's log, closed and indexed
my $r = $store->query('log | where severity >= error', from => $t0);
printf "%d rows, %d scanned\n",
scalar @{ $r->{rows} }, $r->{meta}{scanned_rows};
my $g = $store->graph; # the service map
my $t = $store->trace($hi, $lo); # one trace, assembled
DESCRIPTION
Ingest appends to a per-worker log and answers. This is everything that happens afterwards: closing a log so it stops moving, summarising it so a query can skip it, and reading records back out of what is left.
A sealed log is the unit of the read side
The live log is being appended to by the worker that owns it, so a reader gets whatever complete frames exist at the instant it looks. That is correct and it is not stable, and an index over a file that is still growing is an index that is already wrong.
Sealing writes the trailer, renames the file, and computes the summary beside it in one pass. After that the file never changes again, so its summary can never go stale - which is what lets a range query decide to skip a segment from a hundred bytes instead of by opening it.
A live log has no summary and is therefore always read. That is bounded by the seal threshold rather than by hope.
The summary is the index
Every figure in a sidecar is one a query would otherwise scan a whole segment to learn: the time span, the counts per signal, the severity histogram, the services seen, and the service graph.
The graph especially. Accumulating it at seal is what makes the service map a read of a few hundred bytes per segment; deriving it at read time would mean walking every span in the retention window to draw one picture, and a picture nobody waits for is a picture nobody looks at.
Nanosecond arithmetic is not double arithmetic
A unix nanosecond timestamp passed 2^53 in 2255, and a duration added to one overflows a double long before that. Timestamps arrive as decimal strings wherever a UV cannot hold them, and the comparisons and the two sums this module needs are done on the strings.
Getting this wrong does not raise anything. It sorts an hour of an incident into the wrong place.
CONSTRUCTOR
new
my $store = Punk::Observe::Store->new(%opt);
dir is the store root and tenant selects the subtree beneath it, defaulting to default. seal_bytes is the size at which a live log is sealed, and max_rows the ceiling on a single read.
METHODS
wal_path
The live log this worker appends to, creating the directory if it is absent.
seal
my $path = $store->seal;
Seals this worker's live log, renames it to a segment, and writes its summary. Returns the segment path, or undef when there was nothing to seal.
seal_if_full
$store->seal_if_full($bytes_just_written);
Tracks the live log's size and seals it once it passes seal_bytes. The size is read from disk once per worker and tracked from there, so this costs nothing per batch.
segments
my $segs = $store->segments;
Every file the read side can see: sealed segments with their summaries, and live logs without. Each carries path, name, sealed, bytes and index.
records
my ($recs, $meta) = $store->records(from => $t0, to => $t1);
Records in the range, newest first, in the shape "decode" in Punk::Observe::Decode produces. %meta reports scanned, skipped (segments the range excluded without opening), files, degraded and truncated.
rows
The same, mapped into the executor's row shape.
row
my $row = Punk::Observe::Store->row($record);
One record as a row. The two differ in exactly two places, and both are load-bearing: the kind is a name rather than a number, and service is lifted out of the attributes because every query filters on it and no query should have to know where it lives.
query
my $r = $store->query($source, from => $t0, to => $t1);
Parses, plans and runs a query over the range. The result is "run" in Punk::Observe::Exec's, plus store describing what was read.
Read the metadata. A caller that renders rows without checking meta->{truncated} renders a partial answer as a complete one.
graph
my $g = $store->graph(from => $t0);
The merged service graph: edges, each with caller, callee, count, errors and dur_max, and services with a record count each. caller is * for the synthetic root, which is where traffic from something uninstrumented arrives.
traces
my $r = $store->traces(min_duration => 500_000_000, errors_only => 1);
Trace summaries in the range, slowest first. This is the search; trace assembles one.
trace
my $t = $store->trace($trace_hi, $trace_lo);
One trace, assembled: every span in tree order with depth, offset from the trace's start, duration, service, name and attrs, plus roots, cycles and orphans.
A trace whose parent chain loops still returns. Broken instrumentation is a thing to show, not a thing to refuse.
retain
my $r = $store->retain(bytes => 2 * 1024 ** 3);
Deletes whole segments, oldest first, until the store fits the budget. Returns { deleted, freed, kept, bytes }.
Deletion is by unlink and never by truncating a file a reader may have open: a reader mid-query keeps its copy until it lets go.
stats
What the status screen shows, read from the sidecars rather than from a scan.
unindexed counts sealed segments with no summary and orphan_index counts summaries with no segment. Both are halves of something that was interrupted, and both are worth saying out loud rather than quietly under-reporting.
mapped_deleted is bytes unlinked while a reader still holds them open, which is disk that is occupied and invisible to du. This store cannot accumulate any: a read copies a segment and lets go of it inside the call, so a retention pass never runs against a live mapping. It is reported because a design property nothing displays is one nobody can check.
dir
tenant
wal_dir
The store root, the tenant subtree beneath it, and the directory the logs and segments for that tenant live in.
PRIMITIVES
The pieces the methods above are built from, useful on their own and documented because they are callable.
ncmp
nadd
nsub
my $order = Punk::Observe::Store::ncmp($a, $b);
my $sum = Punk::Observe::Store::nadd($t, $duration);
Compare, add and subtract nanosecond instants. ncmp compares digits rather than parsing, so it keeps working on values too wide for a uint64 - which is what stops a future timestamp format sorting into the wrong century. nadd saturates and nsub clamps at zero, because a horizon that wrapped to zero would delete everything.
scan
my ($recs, $meta) = Punk::Observe::Store::scan($bytes, \%filter);
Replays one log image, filters it by from, to and kind, orders it newest first and caps it at limit. %meta carries scanned, kept, truncated and the replay's reason.
scan_dir
my $segs = Punk::Observe::Store::scan_dir($dir);
Lists a store directory: every segment and live log in it, with its size and its parsed summary.
summarise
my $s = Punk::Observe::Store::summarise($bytes);
The counts of one log image - records, time span, and the tallies per signal and severity. Counts only: it builds no service graph and counts no traces, which is why seal does not use it.
read_index
write_index
The sidecar beside a sealed segment, read and written.
wal_dir
my $dir = $store->wal_dir;
Where this store's logs live: dir/tenant/wal. Every path the store builds is rooted there, which is what makes a tenant a directory rather than a column.
row
my $row = Punk::Observe::Store->row($record);
One record in the shape the executor reads. records and rows build this during the scan and never call here; this is for a caller that already has a record and wants the other shape without going back to the log.
slurp
file_size
mkpath
rename_file
File operations, named so they cannot be confused with perl's own. In particular rename_file is not rename: a subroutine of that name in this package would shadow the builtin for every caller in it.
retain_dir
my $out = retain_dir($dir, $budget_bytes, $keep_min);
The whole of retention against one directory: list, size, order by name, and unlink oldest first until the total is under budget. Returns deleted, freed, kept and bytes.
$keep_min is a floor, and it is what stops a misconfigured budget emptying a store: a budget of one byte would otherwise delete everything.
The sidecar is unlinked with its segment. One left behind is the other half of an interrupted pass, and "stats_dir" counts those.
stats_dir
my $s = stats_dir($dir);
Every figure the status screen shows, read from the sidecars rather than by scanning. Returns the counters, service as a merged name-to-count table, and three numbers that are about the store's own health rather than its contents: wal_depth, unindexed and orphan_index.
A sealed segment with no sidecar is unindexed - a seal that was interrupted. It is still readable and still counted, because a number that is low is different from a number that is wrong.
CONSTANTS
KIND_METRIC
KIND_LOG
KIND_SPAN
The three record kinds, as the record carries them: 1, 2 and 3. A record is one shape and the signals are three views of it, so these select a view rather than a type.
SEE ALSO
Punk::Observe, Punk::Observe::WAL, Punk::Observe::Exec, Punk::Observe::Trace, Punk::Plugin::Observe