NAME
Punk::Observe::Retain - compaction, rollups and deletion
SYNOPSIS
use Punk::Observe::Retain;
my $s = Punk::Observe::Retain::sweep(\@segment_paths, $cutoff_ns);
printf "freed %s bytes from %d segments\n",
$s->{bytes_freed}, $s->{unlinked};
my $r = Punk::Observe::Retain::rollup(
[ map { { t => $_ * 1_000_000_000, v => $_ + 0.0 } } 1 .. 3600 ], 3);
printf "avg over the hour: %s\n", $r->{value_1h};
DESCRIPTION
Data is deleted by whole block - two hours - and never by record. Deleting one line would mean rewriting a compressed block, which would make a segment mutable and remove the property every reader depends on.
So "delete this one log line" is not supported, and the granularity is the answer to why.
Deletion cannot break a reader
The deletion primitive is unlink(2), never ftruncate(2).
A reader holding a memory map of an unlinked file keeps reading it correctly: the name is gone, the data lives until the last mapping drops. A reader holding a map of a truncated file takes SIGBUS on the next touch - not an error return, a signal, killing the worker mid-request for every connection it was holding.
"sweep" reports truncate_calls, and it must be zero.
A deleted file that is still mapped is still on the disk
A large segment unlinked an hour ago occupies its space until the last worker drops its mapping, so the space a retention policy promises and the space the filesystem reports can differ with no visible explanation. That is what the generation table is for: a segment is removable when no reader holds its generation.
Downsampling refuses percentiles
Two tiers, at five minutes and one hour, each point carrying {count, sum, min, max, last}. That set is closed under merging, so an hourly point is built from twelve five-minute points without returning to the raw data. It answers count, sum, avg, min, max and rate exactly.
It cannot answer a percentile, and it refuses to. There is no function of those five numbers that yields a p95, and every approximation that looks close is wrong in the tail - which is the only part of a latency chart anybody reads. "rollup" returns a refusal naming the alternative rather than a plausible wrong number.
Where the series is a histogram the percentile merges exactly from the bucket counts, and that is the supported path for a long-range percentile.
Counter resets are carried into the rollup, because the raw points that would reveal one are dropped afterwards.
AGGREGATE CODES
Passed as an integer to "rollup":
1 count 4 min 7 p90
2 sum 5 max 8 p95
3 avg 6 p50 9 p99
10 distinct
Codes 6 to 9 are refused over a downsampled range.
FUNCTIONS
merge
my $out = Punk::Observe::Retain::merge([ \@run_a, \@run_b ]);
Merges sorted runs into one ordered stream and collapses duplicates. Each record is a hashref taking t, series and kind.
{ records => [ { t, series }, ... ], emitted => 240, duplicates => 12 }
The merge is deterministic: the same runs in the same order always produce the same output, which is what makes re-compaction idempotent rather than a source of duplicate points.
duplicates counts records collapsed because an earlier compaction had already emitted them.
rollup
my $out = Punk::Observe::Retain::rollup(\@points, $agg);
Folds raw points into the five-minute tier, promotes that to the hourly tier, and answers $agg from each. Each point is a hashref taking t, v and reset.
{
buckets_5m => [ { t, count, sum, min, max, last, resets }, ... ],
n_5m => 12, n_1h => 1,
ok_5m => 1, ok_1h => 1,
value_5m => ..., value_1h => ...,
resets_5m => 0, resets_1h => 0,
}
value_5m and value_1h are present only when the corresponding ok is true. When ok_5m is false, refusal carries the message explaining what to do instead - the aggregate was one the tier cannot answer.
The hourly value must equal the five-minute value for every aggregate the tier supports. That is the closure property the whole design rests on.
sweep
my $out = Punk::Observe::Retain::sweep(\@paths, $cutoff_ns);
Marks every segment whose data ends before $cutoff_ns and unlinks it.
{
considered => 40, marked => 12,
unlinked => 12, kept => 28,
bytes_freed => 41943040,
truncate_calls => 0,
}
truncate_calls must be zero. A non-zero value means something took ftruncate to a segment, and a reader mapping it will take SIGBUS.
read_through_unlink
my $out = Punk::Observe::Retain::read_through_unlink($path);
Opens a segment, keeps the mapping, unlinks the path underneath it, and reads every record again through the same mapping.
{
opened => 1, unlinked => 1, records => 600,
sum_before => ..., sum_after => ..., same => 1,
}
same is the property: the data read identically after its name was removed. This is why deletion is unlink and not ftruncate.
generations
my $busy = Punk::Observe::Retain::generations(
[ acquire => 1, busy => 1, release => 1, busy => 1 ]);
Drives the generation table with a flat list of (operation, generation) pairs and returns an arrayref holding the result of each busy query, in order. Operations are acquire, release and busy.
A generation is busy while any reader holds it. Segments belonging to a busy generation are not removable, which is what stops a sweep pulling the ground from under a query already running.
block_removable
my $bool = Punk::Observe::Retain::block_removable($segments, $expired_all);
Whether a block can be removed: it must have no segments still referencing it, and every segment that did must have expired. Both conditions, because removing a block that one segment still points at turns a query into a hole.
SEE ALSO
Punk::Observe, Punk::Observe::Segment, Punk::Observe::Metric