Conversation
torstei
force-pushed
the
feature/tally-no-seal
branch
from
September 8, 2026 22:16
1bd97a1 to
43f7769
Compare
torstei
force-pushed
the
feature/tally-writer
branch
2 times, most recently
from
September 8, 2026 22:37
1224dae to
64d1d5e
Compare
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
force-pushed
the
feature/tally-writer
branch
from
September 9, 2026 05:25
64d1d5e to
621d533
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 DIRwrites 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 --provisionplus the follower runningtally --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-flush47,523 samples either way, and
--unpackrenders back exactly whattallywrote 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
createdstamp, 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.⚠
Optionrather 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 howtimberfs identitytreats the same absence.The source store's id, taken from the records stream's own
sourcerecord, 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:
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
sourcematching the source store's bark id exactly.Left short of done, deliberately:
labelsis 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. Andretain_ms/retain_bytesare declared, not enforced:retain_msvalidates 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 aSinkper 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
!metawould be the same fact in two places. Nothing reaches stdout under--blocks.⚠ And routing the provisional drain into blocks is correct only because
Block::mergereplaces 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 atidle()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_offsetkeeps the position behind every open bucket) and not for a repair; a repair is a bounded read, not a rewind —query --records --from/--to | tallyalready 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,
fmtandclippy --all-targets -- -D warningsclean).🤖 Generated with Claude Code