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