Skip to content

tally --blocks writes the grid instead of the lines - #185

Open
torstei wants to merge 8 commits into
feature/tally-no-sealfrom
feature/tally-writer
Open

torstei wants to merge 8 commits into
feature/tally-no-sealfrom
feature/tally-writer

Conversation

@torstei

@torstei torstei commented Sep 8, 2026 •

Copy link
Copy Markdown
Owner

Base is #184. Six commits, +644/−21 over 6 files. Two pieces: the block writer, and the manifest identity it records into.

What this provides, and what it does not

timberfs tally --blocks DIR writes the numbers as columnar blocks instead of tally lines on stdout — and a block store now carries its own id and the id of the tape it was derived from.

⚠ The flag is a harness, not a fleet feature. Nothing in a provisioned deployment invokes it: production is tally --provision plus the follower running tally --run, and this touches neither. What it buys is that the fold has a block destination, and that destination provably agrees with the shipped one.

⚠ And it names a DIRECTORY, which every other timberfs argument deliberately does not — "a forest is the ONE thing a timberfs command names by path; every other argument names a store, and a store is found by what it declares." That is the absence of an interface rather than a choice: nothing could address a block store until this PR gave it an id, and there is still no forest to find one in. The flag's help and the man page both say so, so the scaffold cannot quietly become the interface.

The writer, verified against the shipped path

300,000 real log lines from a production performance log, through that site's own extractor, into a store and out through feed's records stream:

--block-flush block writes vs the line path
1000 43 ✅ 0 differences
10000 5 ✅ 0 differences
50000 1 ✅ 0 differences

47,523 samples either way, and --unpack renders back exactly what tally wrote as lines. So it is the same tally stored differently, and the merge path is right across 43 commits as well as one.

It attaches at the one seam the fold already had: every batch — sealed, provisional and final — goes through emit(), so routing it to a block store is a single branch.

⚠ The buffer is the write-amplification control, not a latency choice. A block is a day, so each commit rewrites up to a megabyte, and one real day is ~268,000 samples — hence a default of 50,000. Buffering costs visibility (a sample is not in a block until flushed); losing it to a crash costs a re-read, the position not having moved. A WAL for samples is the durable form and is not this.

The manifest identity

Its own id — a UUID with a created stamp, minted once and never touched, following bark's rule for bark's reason: a path is an address, the id is what the store IS.

⚠ Option rather than required, because a manifest written before identity existed has none — and minting one on read would hand an old store a new identity in silence. Missing is a defect to repair deliberately, which is how timberfs identity treats the same absence.

The source store's id, taken from the records stream's own source record, which carries one. Load-bearing rather than provenance: a citation is an offset into that store's tape.

⚠ A second source is REFUSED, and this is the part worth having:

Error: these blocks were derived from store 8a169289-… and this run reads
6c77265f-… — a citation is an offset into ONE tape

Verified end to end. Without it, one store's records folded into another's blocks would leave every citation pointing into the wrong tape — silently, the offsets being plausible either way. The happy path checks out too: a real run wrote its own id plus a source matching the source store's bark id exactly.

Left short of done, deliberately: labels is a field nothing populates, because the source record carries an id but no labels — they arrive from whatever resolves the source store, which is the provisioning. And retain_ms/retain_bytes are declared, not enforced: retain_ms validates as a whole number of blocks, but nothing sets them, so no manifest claims a policy that is not honoured.

Two boundaries drawn on purpose

⚠ One store in, one directory out — why this is on the stdin path and not --run. A provisioned run serves a selection, with a Sink per source store, and where each one's blocks go is a provisioning question.

⚠ The unit announcement is suppressed, not redirected. A block store carries its own definitions and a unit is a property of one, so emitting !meta would be the same fact in two places. Nothing reaches stdout under --blocks.

⚠ And routing the provisional drain into blocks is correct only because Block::merge replaces a cell. A provisional bucket states its total so far; the complete one later states the whole total again. When merge becomes additive for partials this double-counts — the comment at idle() says so.

Notes added while building

Three things the implementation forced into the design note: a record stream is forward-only, which is fine for a crash (safe_offset keeps the position behind every open bucket) and not for a repair; a repair is a bounded read, not a rewind — query --records --from/--to | tally already exists, so recomputing a window never stops the live path; and the fold is fed sequentially by a follower, with the measurements for why parallel workers are not the first thing to reach for.

Seven new tests (493 lib tests pass, fmt and clippy --all-targets -- -D warnings clean).

🤖 Generated with Claude Code

@torstei
torstei changed the base branch from tally to feature/tally-no-seal September 8, 2026 21:52
@torstei
torstei force-pushed the feature/tally-no-seal branch from 1bd97a1 to 43f7769 Compare September 8, 2026 22:16
@torstei
torstei force-pushed the feature/tally-writer branch 2 times, most recently from 1224dae to 64d1d5e Compare September 8, 2026 22:37
torstei and others added 8 commits September 9, 2026 07:24
The writer, at the one seam the fold already had: every sealed and
provisional batch goes through emit(), so routing it to a block store rather
than to stdout is one branch. Verified on 300,000 real log lines — the blocks
render back to exactly what the line path wrote, at every flush size.

⚠ It BUFFERS, and the buffer is the write-amplification control rather than a
latency choice: a block is a day, so each commit rewrites up to a megabyte.
Measured, the same 47,523 samples cost 43, 5 or 1 block writes at
--block-flush 1000, 10000 and 50000, and all three answers are identical. The
cost is visibility — a sample is not in a block until it is flushed — and
losing the buffer to a crash costs a re-read, the position not having moved.
A write-ahead log for samples is the durable form and is not this.

⚠ One store in, one directory out, which is why it is on the stdin path and
not on --run: a provisioned run serves a selection with a sink per source
store, and where each one's blocks go is a provisioning question.

⚠ The unit announcement is suppressed here, not just redirected: a block
store carries its own definitions and a unit is a property of one, so writing
!meta would be the same fact twice, one of them a marker the grid does not
hold. Nothing reaches stdout under --blocks.

⚠ And routing the PROVISIONAL drain into blocks is correct only because
Block::merge replaces a cell — a provisional bucket states its total so far
and the complete one states it again. When merge becomes additive for
partials this double-counts, and the comment at idle() says so.

Three tests, and the repository's own two caught the undocumented flags again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Calling it a feature overstated it, and overstating a scaffold is how a
scaffold becomes permanent. Nothing in a provisioned deployment runs this:
the production path is --provision plus the follower's --run, and this
touches neither. What it is for is exercising the writer against a real
store until --run can write blocks itself.

⚠ And it names a DIRECTORY, which every other timberfs argument
deliberately does not — "a forest is the ONE thing a timberfs command names
by path; every other argument names a store, and a store is found by what
it declares". That is not a considered interface choice but the absence of
one: a block store has no identity yet, its manifest's id being designed
and unbuilt. When there is one, this takes a store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…repair

The design leans on re-derivation and never said how a forward-only stream
supplies it. Two cases, and they separate:

A crash needs no seek, because Roller::safe_offset holds the reported position
behind every open bucket — so a restart re-sends from before that bucket's
first entry, re-folds it whole, and the complete total replaces the partial
one. ⚠ That is the invariant the block writer's How::Merge rests on, and it is
invisible from the writer: tightening safe_offset corrupts blocks from code
that never mentions them, and it has been optimisation-bait once already.

A repair does need to go back, and it is not the writer's job — it is
resetting a follower's position, an operator act. ⚠ And there is no verb for
it: follower has create/list/status/update/delete/run, update changes a
declaration rather than state, and a position lives in positions.json. So
"fix the regex and recompute last week" today means deleting a follower or
editing that file by hand. Added to the open list, because it is what makes
re-derivation an operation rather than a plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tally is not only a consumer: query --records --from/--to | tally is a
bounded direct read of the source and already exists. So recomputing a window
is a one-shot pass while the follower keeps going forward — no position moved,
the live path never interrupted. ⚠ What it needs is How::Regenerate rather
than Merge, which the writer does not expose: merging a recomputation would
replace cell by cell and leave series the new definition no longer produces
standing. The open item was "a rewind verb" and that was the wrong item.

⚠ And safe_offset conservatism is a tape-shaped leftover rather than a law —
a consequence of merge replacing a cell, dissolved by additive partials. The
previous commit wrote it into the design as permanent, which is the exact
regression the "what a tally is NOT" section exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The note said the position is efficiency and recovery is re-derivation, and
never said why a tally is a consumer at all. It is: the fold reads its source
beginning to end, once, and the follower holds the position durably, holds the
source's retention back while the tally is behind, and supplies the registry
and lifecycle.

⚠ Parallel workers, recorded so they are not re-proposed without the numbers.
They need additive partials — a third use, after spilling and repair — since
two workers can contribute to one bucket and a replacing merge erases one of
them; and they must partition by source position, a day's entries not being
contiguous in the source and there being no per-chunk logline range to find
them by. Measured over 300,000 real lines the gain is small: records 0.52s,
the fold 4.53s, blocks 0.05s, so blocks are 1% and a day is ~47s — and the
largest possible backfill is the SOURCE's retention, weeks rather than years.

⚠ And the obvious fold optimisation is absent: the metric with the smallest
regex is joint-most expensive, being a histogram of 22 buckets that turns one
line into 22 samples. Cost is per sample and per metric, not per regex byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DECLARE is bark's vocabulary and a block store has no bark, so what a .bark
carried has to live in the manifest. This is the identity half of it.

Its own id, a random UUID minted once with a created stamp and never touched
after — bark's rule for bark's reason: a path is an address and the id is what
the store IS, across renames, moves and copies. ⚠ Option rather than
required, because a manifest written before identity existed has none and
minting one on read would hand an old store a new identity in silence.
Missing is a defect to repair deliberately, which is how timberfs identity
treats the same absence.

The SOURCE store's id, taken from the records stream's own source record,
which carries one. Load-bearing rather than provenance: a citation is an
offset into that store's tape and means nothing without knowing which tape.
⚠ And a second source is REFUSED — verified end to end, feeding another
store's records at an existing block directory stops with both ids named.
Without that, one store's records folded into another's blocks would leave
every citation pointing into the wrong tape, silently, the offsets being
plausible either way.

Labels are a field the provisioning will populate: the source record carries
an id but no labels, so they arrive from whatever resolves the source store,
not from the writer.

Retention is declared, not yet enforced: retain_ms is checked to be a whole
number of BLOCKS, drop_before removing blocks whole so a finer value would be
a promise the store cannot keep. ⚠ Nothing sets it yet, so no manifest claims
a policy that is not honoured; enforcement is the next piece.

Verified on a real run: the block store carries its own id and a source
matching the source store's bark id exactly. Four tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flag's help explained that it names a directory where every other
timberfs argument does not, and gave the reason as "a block store has no
identity yet, its manifest's id being designed and unbuilt" — which a later
commit in this same branch made false. The manifest carries an id; what is
missing is anything that searches for block stores by it, which is a smaller
and true claim.

⚠ Text written against what something used to be goes stale when that
changes, which is the sharper reason not to write it: this went wrong inside
one branch, between two commits a few hours apart.

Writer's doc loses a pointer at the WAL as "the open question this buffer
stands in for", which is plan narration rather than something the code needs,
and states its two costs instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three passages this branch added to the design note narrated their own
discovery rather than the design.

"Settled, with numbers" and "parallel workers were considered" say something
about a conversation; the reasons parallelism is not reached for stand on
their own. "Worth recording so it is not re-proposed" likewise — the finding
is that cost is per sample and per metric rather than per regex byte, and that
holds whether or not anyone proposed otherwise.

⚠ And the safe_offset warning carried "it sat in the middle of the
51-entries/s deadlock, so it has been optimisation-bait once already" inside
the one paragraph that has to survive as a constraint. What a reader must not
do is advance safe_offset further, because blocks are then corrupted by code
that never mentions them; the incident that taught us belongs in
consumer-holding.md, which argues it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@torstei
torstei force-pushed the feature/tally-writer branch from 64d1d5e to 621d533 Compare September 9, 2026 05:25

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant