A tally follower ran at 51 entries a second, and its numbers were wrong - #179
Merged
Conversation
The consumer protocol can say "I took these" and "I dropped these", and a tally consumer holding an open bucket is neither: it has the entries, does not want them again now, and will need them again if it restarts. Reporting the conservative position instead deadlocks against the follower's park — the store is parked until its position moves, and the position cannot move until entries the park is withholding advance event time. Measured: 51 entries/s against the 310,000/s the extractor itself does, the 10-second force-seal that breaks the standoff being the only thing that advances it, so throughput is batch_size / 10s and nothing else. The same force-seal writes each bucket as fragments, which the reader's newest-wins rule resolves to the last one (1536 entries counted, 512 reported, no marker); and the wall-clock advance beside it outruns the data during the stall, after which every batch is displaced whole. The note records the measurements, the `taken` field that separates flow control from the durable position, the three defects that are wrong on their own merits, and the four hot-loop costs that are next once the ceiling lifts. It amends the "took it or dropped it" dichotomy in the consumer protocol note and the "safe_offset is already the answer" line in tally's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The consumer protocol could say "I took these" and "I dropped these". A tally consumer holding an open bucket is neither: it has the entries, does not want them again now, and re-derives them from the source bytes if it restarts, so its position must stay behind them. The follower reads a position as flow control too — a store with anything outstanding is not read again — so one number for both parked such a consumer on its own correctness, and only a ten-second idle force-seal broke the standoff. `progress` now carries an optional `taken` beside `offset`: how far the consumer has read, for pacing only, never persisted, absent meaning equal. The park and the read gate on it. Measured over a 500k-entry apache store at the unchanged batch size of 512: 51 -> 51,949 entries/s, and the tape is now byte-identical to one in-memory pass over the same lines. The force-seal was also writing wrong numbers, and evicting the bucket was why: each tick emitted only what had arrived since the last, and a reader resolving a bucket to its newest line took the last fragment for the whole (512 of 1536 entries, no marker). A quiet tick now states a changed open bucket and KEEPS it, so the next line supersedes it with the complete total — the revision both readers already resolve. `advance_to` is clamped to last_event + grace so wall clock seals what event time has left and never the bucket it is in, which is what displaced whole batches before. Two more found while building. `feed` never wrote stream-end, so closing the pipe read as truncation: `tally --run` bailed, abandoned what it held and exited 1 on every clean stop. And the follower's batch size reached the operator as "stopped at --max 512", once per batch, forever. The docs are read against the diff: timberfs-records(5) gains `taken`, timberfs(1)'s tally section no longer describes the force-seal, and the plan note records that its own first proposal for the quiet tail was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tally section claimed both readers read a tally tape newest-line-wins. timbergraph does; timberview is a tape viewer with no aggregation, so it has nothing to resolve and shows every line, which is what it should do. Naming it as a resolver would have someone expect a revision to hide the line it revises. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release doc pass, question one: a behaviour change adds a correct paragraph and leaves the incomplete one standing. Both places that introduce the consumer protocol to a reader — timberfs(1)'s follower section and deployment.md — described a report as a watermark with two outcomes, a receiver down and an entry refused. There is a third now, and those are the pages someone deploying a tally follower actually reads; timberfs-records(5) had the field and nothing pointed at it from here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Newest-line-wins was documented as a property of READING a tape, on the stated grounds that "nothing emits a revision" — true when it was written and false since a quiet tick began stating an open bucket and keeping it. A running follower now emits revisions as a matter of course, so this is the format's most consequential rule for anyone writing a reader, and it was nowhere a reader would look: the man page taught the grammar, the five measures and the citation, then explained revisions 130 lines further down inside LATENESS, and timbergraph(1) — the aggregating reader — never mentioned it at all. So: stated in the FORMAT description in timberfs(1), stated in timbergraph(1) as something any other tool over the same tape must also do, corrected in tally.md, and corrected in timbergraph.py's own docstring, which still gave the old reason for the rule it implements. Verified against a tape carrying three revisions of one bucket: timbergraph resolves it to one bucket, count=2, not 6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Design note for the defect behind "tally is slow catching up". Docs only — no code, and the fix is a protocol amendment that wants settling before it is written.
What was measured
A real pipeline: a 500k-line apache store,
tally --provision,feed -- timberfs tally --run, the shippedtimberfs-apache-combineddocument.--try, 2 metrics)BATCH_ENTRIESof 512Throughput is
batch_size ÷ 10 sand nothing else — 512 → 51/s, 4096 → 341/s, 65536 → 5750/s. Anything busier than ~51 entries/s falls permanently behind.The numbers already written are wrong, in two ways, and only one leaves a marker. 1536 entries delivered and counted, resolved by
timbergraph.readto 512, no!late/!cap/!drop. And once the stall has run long enough, every batch is displaced whole — reproduced with a 10 s/0 s window: 5632 entries spanning 28 s of log time placed across 50+ s of buckets, ten!late … count=512markers.Why
Two rules, each right on its own:
safe_offset, the oldest byte an open bucket depends on, so a restart re-derives identical lines.Composed they deadlock:
safe_offsetis the first entry of the open bucket, so nothing is ever acknowledged, so the park withholds the entries that would advance event time and seal it. The consumer's own report stream shows it:Only
QUIET_TICKS × IDLE_TICK= 10 s of silence breaks it, viafinish()— and that force-seal is what fragments the buckets, while theidle()wall-clock advance beside it is what displaces them.The fix in the note
The protocol has took it and dropped it; a consumer holding an open bucket is neither. One optional field on the report that already exists:
offsetkeeps its exact meaning and use (durable, persisted, the retention floor);takenis flow control only, in memory, resetting tooffseton restart. Omitted meanstaken == offset, so no existing consumer changes and a shell consumer is still threeprintfs. The follower parks on and reads fromtaken, which preserves the anti-duplicate property the park exists for.⚠ Deliberately not a raised
--batch-size: at 65536 the undercount only shrinks to 5.5%, because the batch-edge buckets are still fragments, and a batch that must spanwidth + graceof the busiest store's traffic is a number the operator would have to compute and get silently wrong.The note also records three things wrong on their own merits (
finish()is not a mid-stream flush;idle()'s advance needs a live-edge gate; the--maxnote leaksBATCH_ENTRIESat one line per batch), the four hot-loop costs that are next once the ceiling lifts — including a 4.16 s → 61.7 s cliff inRoller::add's series-count scan — and the open questions I did not settle.For the record, since it was the first suspicion: the regexes are compiled once and were never a suspect.
Test gap
tally_provisioning_end_to_endfeeds 3 entries — fewer than one batch — so it cannot fail on any of this. The assertion that catches all three: cross several batch boundaries and require the resolved tape to total the entries fed. An integration test of the feed→tally pair; nothing here needs a VM.Operational note
Tally is being stopped in production. The tally stores already written are not salvageable — an undercount of unknown factor early, displaced buckets after — so the remedy is to drop them and re-derive from a reset position once this is fixed, which is also the best check on the fix.
!late … count=512on a tally store is this defect's signature and is greppable today.🤖 Generated with Claude Code