Skip to content

A tally follower ran at 51 entries a second, and its numbers were wrong - #179

Merged
torstei merged 5 commits into
mainfrom
feature/tally-consumer-holding
Sep 7, 2026
Merged

A tally follower ran at 51 entries a second, and its numbers were wrong#179
torstei merged 5 commits into
mainfrom
feature/tally-consumer-holding

Conversation

@torstei

@torstei torstei commented Sep 7, 2026

Copy link
Copy Markdown
Owner

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 shipped timberfs-apache-combined document.

the extractor alone (--try, 2 metrics) 310,000 entries/s
the pipeline at the hard-coded BATCH_ENTRIES of 512 51 entries/s

Throughput is batch_size ÷ 10 s and 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.read to 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=512 markers.

Why

Two rules, each right on its own:

  • the park — a store with anything unacknowledged is parked, which was measured at 99% of a core without it;
  • the watermark — tally reports safe_offset, the oldest byte an open bucket depends on, so a restart re-derives identical lines.

Composed they deadlock: safe_offset is 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:

progress offset=0        <- first entry opens a bucket; the watermark pins to byte 0
                            ...nothing for 10 s...
progress offset=93135    <- one 512-entry batch, acknowledged at once
progress offset=186350   <- 10 s later, the next

Only QUIET_TICKS × IDLE_TICK = 10 s of silence breaks it, via finish() — and that force-seal is what fragments the buckets, while the idle() 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:

progress id=<id> offset=<safe> taken=<flow-control>

offset keeps its exact meaning and use (durable, persisted, the retention floor); taken is flow control only, in memory, resetting to offset on restart. Omitted means taken == offset, so no existing consumer changes and a shell consumer is still three printfs. The follower parks on and reads from taken, 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 span width + grace of 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 --max note leaks BATCH_ENTRIES at 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 in Roller::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_end feeds 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=512 on a tally store is this defect's signature and is greppable today.

🤖 Generated with Claude Code

torstei and others added 2 commits September 7, 2026 15:41
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>
@torstei torstei changed the title A tally follower runs at 51 entries a second, and its numbers are wrong A tally follower ran at 51 entries a second, and its numbers were wrong Sep 7, 2026
torstei and others added 3 commits September 7, 2026 16:30
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>
@torstei
torstei merged commit a492dcc into main Sep 7, 2026
7 checks passed
@torstei
torstei deleted the feature/tally-consumer-holding branch September 7, 2026 16:35
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