Skip to content

feat(stats): hand transactions to the tracker over a channel, not a lock - #80

Merged
bdchatham merged 87 commits into
mainfrom
brandon2/plt-1080-handoff-channel
Sep 1, 2026
Merged

feat(stats): hand transactions to the tracker over a channel, not a lock#80
bdchatham merged 87 commits into
mainfrom
brandon2/plt-1080-handoff-channel

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

TOT-010, TOT-014, TOT-018. Seventh in the outcome-tracking stack. Closes User Story 3.

The stall

The sender called Register on the goroutine that had just completed a send, and Register took the registry lock. The reap loop holds that same lock while it walks every in-flight transaction. So a reap sweep landed directly in the latency this package reports, and the measurement slowed the thing it measures.

The hand-off

Submit is a non-blocking send into a channel. A dedicated goroutine drains it and does the admission.

func (t *InclusionTracker) Submit(ctx context.Context, tx *types.LoadTx) {
	select {
	case t.submit <- tx:
	default:
		// count the drop, then report it
	}
}

A full channel drops and counts. Both halves are required: a hand-off that blocks brings back the stall this removes, and one that drops in silence leaves an accepted transaction with no terminal state, which breaks the conservation identity.

Why the drain does no chain read

This is the requirement that makes the depth derivable rather than guessed (TOT-018).

A drain sharing the head loop inherits that loop's block read. At a few thousand transactions per second no depth absorbs a multi-second stall, so the number would be a guess defended by nothing. A drain that only admits waits as long as the registry lock is held, which a reap sweep bounds at microseconds.

The depth then follows from the configured send rate: one second of headroom, floored for a profile that sets no rate. That is four orders of magnitude above what the drain actually stalls for, and shallow enough that a drain which genuinely cannot keep up says so through dropped_at_handoff rather than hiding in a deep queue.

dropped_at_handoff stops being the count with no producer that #78 flagged.

D-1 and D-2

D-1, the queue depth, is answered here: the clarification said the depth follows once the drain's goroutine is settled, and TOT-018 settles it.

D-2, whether a drop voids the run, stays deferred. Its own condition is un-defer when a run first drops a hand-off outside a deliberate overload test, and that has not happened.

Tests

Existing tests call admit directly where they want deterministic admission, which is what they were written to check. Assertions are unchanged. The hand-off gets its own tests, including a conservation check that drives Submit and the drain concurrently against a deliberately shallow channel.

mutation caught
a full hand-off blocks the sender yes
a full hand-off drops in silence yes
the drain performs a chain read yes
a zero send rate gives a zero-depth channel yes

gofmt, go vet, full suite clean. -race clean on stats and sender.

bdchatham and others added 27 commits August 26, 2026 14:49
The collector counts what the sender submitted. The inclusion tracker counts what
became of those submissions. Neither holds both halves, so neither can say what
fraction of offered work took effect.

This adds the vocabulary and the ledger, and changes no behaviour. The tracker
does not call it yet.

Result names six terminal states. Two distinctions carry the point. Committed and
Failed separate a transaction that did what the workload asked from one that
burned its gas doing nothing, which an inclusion count cannot tell apart. Unknown
separates "the run did not see" from "the chain did not take it", which decides
whether a low goodput ratio is a finding about the chain or about the run.

Failed names what a receipt reports rather than a cause. A receipt carries one
status bit, and separating a revert from an out-of-gas needs a trace call per
transaction that the per-block read budget forbids.

RecordResult keys on the OperationKey the send path already labels its metrics
with, and adds rather than overwrites. Callers reach it from more than one
goroutine, because a block match and a reap sweep both report results.

The result strings are a one-way door: a dashboard query and a saved report both
carry them, so a test pins them. The strings are unchanged by the type's name.

Every guard was checked by breaking what it covers. Overwriting instead of adding
fails two tests. Folding Failed into Committed fails three. A drifted name fails
the string test. Dropping the lock reports a data race.

Requirements: TOT-001, TOT-004, TOT-005, TOT-006, TOT-016. Tasks T003 to T006.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The collector counts what the sender submitted. The inclusion tracker counts what
became of those submissions. Neither holds both halves, so neither can say what
fraction of offered work took effect.

This adds the vocabulary and the ledger. Nothing reports an outcome yet.

Outcome names six terminal states. Committed and Failed separate a transaction
that did what the workload asked from one that burned its gas doing nothing.
StatusUnavailable separates "the run did not see" from "the chain did not take
it", which decides whether a low goodput ratio is a finding about the chain or
about the run.

The zero value is a sentinel, not a state. Committed at index 0 would mean an
unassigned variable, a switch matching no case, or an early return counts as a
commit, silently, which is the failure the type exists to remove. An unset or
out-of-range value counts as Unrecorded instead: it has no legitimate producer,
so a non-zero count means sei-load has a bug and nothing else explains it. The
first one logs, once per run, because a systematic bug would otherwise write a
line per transaction. A run never fails over a counting bug.

recordOutcome now takes an Outcome rather than a string. The metric label was
already fed by bare literals while Outcome.String() produced the same values, so
one wire contract had two independent sources and the test pinned the one nothing
used. A literal still compiles, so this makes re-splitting unnatural rather than
impossible.

status_unavailable rather than unknown: a reader seeing unknown beside expired
cannot tell a chain finding from a measurement finding, which is the confusion
the state exists to prevent. dropped_at_handoff stays, because both alternatives
collided with the dispatcher's own load shed, which RunSummary.Dropped already
counts and which means the transaction never reached the chain at all.

stats/doc.go carries the type map, the three sentinel rules, and the three lock
domains. Lifecycle and ownership are marked absent rather than invented, because
both describe the tracker loop this change does not add.

Guards proven by breaking what they cover: Committed back at index 0, the
out-of-range value vanishing, a drifted frozen string.

Requirements: TOT-001, TOT-004, TOT-005, TOT-006, TOT-016. Tasks T003 to T006.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tracker derived inclusion from the transaction hashes in a block, and a hash
carries no execution status. One state therefore covered a transaction that
committed and one that failed and burned its gas. A run could report a million
accepted, near-perfect inclusion and a healthy p99 while every transaction
failed, and nothing in the output said so.

blockSource becomes receiptSource, backed by ethclient.BlockReceipts. One call
per block either way, so the request count does not move: an earlier design
fetched a receipt per transaction and its cost grew with the load the run
offered, which is the constraint that shaped this one.

blockReceipt is this package's own type rather than a go-ethereum receipt. A
receipt carries eleven more fields the tracker has no business reading, and a
test supplies a hash and a status without constructing one.

matchBlock resolves each matched transaction to Committed or Failed and reports
it, and the two existing outcome sites now route through the same reporter, so
the metric and the collector stay in step.

The tracker holds a collector. The reference runs one way: the tracker may take
the collector's lock, the collector must never take the tracker's state lock.
Nothing takes both, and the field comment is where that is written down.

Reports land outside the registry lock. The sender blocks on it at every send
completion, so work held under it lands in the latency this package reports.

Guards proven by breaking what they cover: every receipt treated as committed,
the operation label dropped, a per-transaction fetch reintroduced, reaped
transactions no longer reported.

Requirements: TOT-001, TOT-002, TOT-009, TOT-015, TOT-016, TOT-017.
Tasks T007, T009, T010, T011, T012.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four independent reviewers read the receipts change. Every one of them found
the same hole: OutcomeStatusUnavailable was defined, documented as the state
that keeps a measurement problem from being reported as a chain problem, and
had no producer. A receipt read that failed left its block's transactions to
age out as expired, which is a claim about the chain that the run had no
grounds to make.

The registry now records a watermark. Each entry stamps the count of unreadable
blocks at registration; a reap compares it against the count now. A higher
count means a block that could have carried this transaction was never read, so
the transaction reaches status_unavailable instead of expired. A transaction
registered after the hole still expires normally.

An empty array is one of those unreadable answers and used to arrive silently.
A node that holds a block but has lost its receipt bodies returns an empty list
with no error, so a whole block of transactions aged out with no log line and
no metric. It now takes the same path as an error, and block_fetch_errors
carries a reason so an operator reads the cause off a dashboard rather than the
pod log.

A receipt carrying a post-state root instead of a status says the transaction
executed and does not say how it ended. Reading that as a failure would invent
a chain result, so blockReceipt carries whether the status was there.

Run proves the endpoint answers before the run starts. A node in validator or
seed mode serves no EVM HTTP at all, and without the probe such a run completes,
reports every transaction un-included and exits zero, which reads as a chain
that accepted nothing.

The tracker can now read receipts from a node other than the one it loads.
receiptEndpoint defaults to Endpoints[0], so a single-node run is unchanged,
and a run that names a second node keeps the read work off the box under load.
That matters more than the request count: a receipts read costs the serving
node work that grows with the block's transaction count.

An endpoint decides what it puts in a receipts array. A null element would have
panicked the head loop, and nothing recovers there, so the run would have died
and lost every result it had gathered.

sender/doc.go states the conservation identity over the terminal states rather
than the older three-term one, and corrects the reorg boundary: first
observation now fixes an execution status, not only a time. stats/doc.go gains
the Lifecycle and Ownership sections it deferred to this change, and the
partition claim it documented now has a test.

Also: context threads through the report path instead of being dropped;
recordOutcome becomes meterOutcome, which is what it does; the two metric
descriptions that contradicted their own series are rewritten; the collector is
required rather than nil-checked, since no caller passed nil; and the comments
that narrated this change rather than stating the present are gone.

Guards proven by breaking what they cover: the reap attribution, the
registration watermark, the empty-array branch, the status-presence branch, the
preflight, the nil guard, the cap-drop report, and a double report.

Requirements: TOT-004, TOT-013, TOT-020, TOT-021. Tasks T008, T013.

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

Two decisions the review surfaced and could not make for itself.

An operator reading seiload_inclusion_outcome_total{outcome="failed"} beside
seiload_run_txs_failed_total sees one word for two layers of the same run. One
is a transaction the chain took and ran to no effect, the other is a send that
returned an error. reverted is what the EVM calls the first, and it does not
collide. succeeded was not available as the other half of the pair, because
OperationStats already carries Successes for the send path.

The label values are declared frozen, and this is the last moment the rename is
free: nothing in the platform repo binds them yet.

The second decision stands rather than changes the code. eth_getBlockReceipts
costs the node answering it work that grows with the square of the block's
transaction count, because it resolves each receipt's index by walking the
whole block and recovers every sender again while doing so. That is a property
of the node, not of this change, and every caller of the method pays it.

The run keeps that cost off the box it is loading by pointing the tracker at a
node that takes no send load, which is what receiptEndpoint is for. The cost
does not disappear: it bounds what the tracking node can keep up with. Both
config.ReceiptEndpoint and receiptSource now say so, and say to measure against
the target chain before turning receipt tracking on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2 found that the previous commit's own fix was worse than the bug it
removed. Three lenses reached it separately and one measured it: 80 of 100
consecutive Sei mainnet blocks return an empty receipts array.

Treating that as a hole marked almost every block. Because the marker only ever
grows and a transaction inherits it for its whole time in flight, one idle block
in a 30-second window converted the entire registry, and expired stopped being
reachable at all. The case the tool exists to detect makes it worse rather than
better: a chain that stopped accepting work produces nothing but idle blocks, so
the run would have reported "I could not see" for the one run where "the chain
took nothing" is the answer.

The premise behind that branch was wrong. sei-chain already separates the three
answers on the wire. A block it cannot see returns null, which arrives as
ethereum.NotFound. Pruned receipts return an error. An empty array means the
block carried no EVM transaction, and it is the truthful answer. So the branch
is gone rather than made conditional, and an array of nothing but nulls, which
is a node answering nothing at all, becomes an error where it is read.

The registry counters now split the way the outcomes do. They reached the
operator through the closing log line while the outcome ledger reached the same
operator through the metric, so one transaction was expired on the surface read
first and status_unavailable on the surface read second.

The preflight failed on the one case a Sei node never produces and passed on
every case it does. A node serving no EVM HTTP refuses the connection, which
classified as other and let the run start blind, and that is the case the doc
comment named first. A gateway that filters methods answers with an HTTP status
carrying the JSON-RPC code in a body the decoder never reads. Both refuse the
run now. Classification leads with the typed checks that hold across servers,
and the two substring tests that cannot be typed are ordered so pruning is
tested before availability, because Sei's two messages differ by one word.

The abort message named --receipt-endpoint. There is no such flag: the setting
is receiptEndpoint in the profile. The one error allowed to end a run told the
operator to use a control that does not exist, and a test now fails on the flag
spelling.

Register takes the context its caller already holds. The comment saying the
caller had none was false; the signature declined it.

A second registration of a hash already in flight overwrote the first, so two
accepted transactions shared one terminal state. It is counted now.

The partition guard covered three of seven states and passed with the whole reap
report loop deleted. It exercises every reachable state, asserts each leg, and
checks that the registry counters and the outcome ledger describe the same
transactions.

sender/doc.go is the file stats/doc.go nominates as owning the conservation
identity, and it still stated it with the retired word and with registered on
the left where dropped_at_cap sits on the right. The two shorter restatements
elsewhere are replaced by a pointer to it.

HasStatus detects a post-state-root receipt and no other shape. go-ethereum
makes both fields optional, so a receipt carrying neither is indistinguishable
from a failure after decoding, and the comment says so rather than implying a
guarantee.

Also: narrow becomes a plain function, matching every other helper in the
package; the cost claim names seid rather than the method, because upstream
go-ethereum is linear and the quadratic belongs to the implementation being
measured; the nolint directive naming a linter this repo does not run is gone
while its explanation stays; the fallback to the load endpoint warns; and the
README documents receiptEndpoint.

Guards proven by breaking what they cover: the idle block, the split counters,
the duplicate registration, the unreachable endpoint, and the all-null array.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shipping receiptEndpoint is what makes this reachable, so it belongs with it.
Two reviewers named the same sequence: heads arrive from the load node, the
receipt node has not committed that height yet, and it answers null. That
arrives as ethereum.NotFound, which counted as a hole with no retry, so a
receipt node trailing by one block produced a hole every block and expired
became unreachable again. The defect this PR just removed, reintroduced through
the topology the PR recommends.

A height the node has not reached is now re-read on the next head. One re-read
is the bound: a node still behind a block interval later is behind rather than
busy, and that is a hole worth counting.

This is TOT-020 pulled forward from phase 3c, for the same reason the unreadable
block attribution came forward from 3b. Leaving it out means merging a change
whose recommended configuration breaks it.

Guards proven by breaking what they cover: a lagging node written off on first
sight, and a node behind forever retried forever.

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

Round 3 reviewed the re-read that round 2's fix needed, and found four defects in
it. Both lenses reached the first two independently.

The re-read stamped the transaction with the arrival of the head that triggered
it, not the head the block actually arrived on. That arrival becomes the
inclusion latency sample, and in the two-node topology this tracker recommends
every height is deferred at least once, so the error landed on every sample
rather than a few. One block interval against a histogram whose first bucket is
half a second. The pending queue carries its own arrival now.

A height still waiting when the heads stopped was dropped, and its transactions
reaped as expired: a claim about the chain for a block nothing ever read. That is
the defect round 2 existed to remove, through a new path. The queue drains
whatever ends the head loop.

A skipped head was never counted either. sender/doc.go called that an undercount
rather than a miscount, which was true when the tracker only counted inclusions
and stopped being true when expired became a claim about the chain. Every height
in a gap is counted now.

The retry bookkeeping was a map that only deleted on the failure path, so a node
that caught up left an entry per block for the life of the run. One queue
carrying a try count replaces it, bounded in both directions, which also raises
the budget past one block: a node two behind used to produce no inclusion data at
all.

The hardened classifier refused a healthy run three ways. A connection reset is
what a busy node does to a caller and read as unreachable. A rate-limited
response whose request id happened to contain those six digits read as the method
being absent, because the body was searched without regard to the status. A bare
404 from an ingress mid-reconcile read as nothing listening. Refusing a healthy
run is worse than the blind run the refusal exists to prevent, so unreachable now
means a refused dial or a name that does not resolve, the body is read only under
a status that means refusal, and the preflight tries three times before it speaks
for the whole run. An endpoint that answers nothing at all across all three is
also a refusal, which is what a dropped route looks like.

The typed checks the last commit added had no test. Every one could be deleted
with the suite still green, because the tests drove error strings the substring
fallback caught anyway. They are covered now, by construction rather than by
text.

Two more from the same review.

The empty-array premise was wrong a second time, in the other direction.
sei-chain swallows a per-hash receipt lookup that comes back not-found and
compacts the slot out, so an empty array can also mean the block's transactions
existed and their receipts were gone. Restoring the branch is not the answer:
reading an empty array as a hole is what made expired unreachable on 80% of real
blocks. The head's gas comes from the consensus result rather than from any
receipt, so gas burned with no receipt returned is the one witness that the two
cases differ, and it is counted rather than acted on. Gas covers Cosmos
transactions too, so treating it as a hole would invent one on any chain carrying
non-EVM traffic.

Any null element in the array is an error now, not only an array of nothing but
nulls. The run cannot see what that element was going to say either way.

A re-read gets a shorter budget than a first read. Head processing is serial and
the node drops a subscription whose head buffer fills, which ends the run, so two
full-length reads in one head cost more than that affords. The head channel is
buffered for the same reason.

Guards proven by breaking what they cover: the arrival stamp, the queue drain on
shutdown, the skipped head, the duplicate leg on both surfaces, the queue
draining after a read, a reset read as unreachable, and a rate-limit status
allowed to speak for the method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by reading my own round-3 fix rather than by review. Treating any null
element as a failed read threw away the receipts the endpoint did send, so a
transaction that committed and whose receipt arrived got nothing and later reaped
as unattributable. That trades a known outcome for an unknown one.

narrowReceipts reports how many elements were null instead of refusing the
array, and receiptSource says so in its signature, because a read that partly
succeeded is a real answer and the interface should be able to express it. The
block is a hole for the transactions the missing part would have named and not
for the ones it named.

Guards proven by breaking what they cover, in both directions: discarding what
arrived, and hiding the loss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The worst defect in this series, and it was mine from the last commit. The
preflight's refusal wrapped the probe's own error with %w, and main treats a
context.Canceled or a context.DeadlineExceeded as the run ending normally. So a
run refused for an endpoint that never answered exited zero: a Complete run that
carried no load, which the nightly harness then reports as a chain that accepted
work and included none of it. The exact false accusation this whole tracker
exists to remove, reintroduced through the path meant to prevent it.

Refusal now carries its own sentinel, and a test asserts that no refusal reads as
the run's context ending.

The rest of what round 4 found, in the order it matters.

The two read budgets were backwards. In the two-node topology this tracker
recommends, the first read of a height returns not-found cheaply and the re-read
is the one that carries the receipts, so the short budget landed on the only read
that mattered. Measured against pacific-1, two of five idle-block reads already
exceeded three seconds. One budget for both.

The deferral bound counted heads, which made the failure a cliff: at four heads
of lag every block read, at five every block became a hole and expired went
unreachable for the whole run. It is a duration now, which is the quantity that
actually matters, and the wait is recorded as a histogram so the drift is visible
before it is crossed rather than after.

The preflight kept only the last attempt's verdict, so two timeouts could erase
an earlier answer that proved the endpoint was there, and refuse the run on
evidence contradicting its own message. A refusal now needs every attempt to
agree on one cause.

Any DNS error refused the run. A resolver answering SERVFAIL is temporary, and
only a name that does not exist is permanent.

An endpoint answering prose rather than JSON stopped being refused when I
narrowed the classifier last round. That is the common operator typo: the metrics
port, the Cosmos RPC port, an ingress default backend. It is as durable a failure
as a refused dial, and it refuses again.

The summary's terms overlapped. A receipt whose status could not be read counted
in both included and status_unavailable, so adding up the closing log line gave
more than the run accepted. included now means the readable ones, and the terms
are disjoint.

A gap logged one line per height, so a fifty-height gap pushed the run's own
summary out of the fifty-line log tail that is the only diagnostic a failed
nightly carries. One record per gap, which changes nothing about attribution
because the reap only asks whether the count rose.

The shutdown sweep marked holes it could not justify. The head loop and the reap
loop end on the same signal, so no reap follows it and those transactions are
already counted as in flight at shutdown. Marking a hole put a failure on the
series that answers "was this run blind?" at the end of every healthy run.

Four causes shared one reason label. A node behind the head, a head never seen, a
height out of budget, and a receipt the node could not produce now have their own,
because the operator's next move differs for each.

One reviewer finding I did not take. It measured that Sei's block gas is
EVM-only, concluded block_empty_with_gas is clean signal, and asked me to act on
it. That measurement was of eth_getBlockByNumber, which sums receipt.GasUsed and
would be circular here. The newHeads header this code reads sums every
transaction's consensus result, Cosmos included, so the counter stays observed
rather than acted on. The comment now names which header, and names sei-chain's
own TODO to change it, because the ambiguity misled a careful reader.

Guards proven by breaking what they cover: the refusal's sentinel, the non-JSON
refusal, agreement across attempts, a resolver blip, the disjoint summary terms,
one record per gap, and a healthy shutdown marking no hole.

Method note: two mutations in this round reported as surviving when they were
really vet failures, and one survived because the fake fell through to success.
The battery checks vet and drives every attempt now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by reading my own last commit. Making the deferral bound a duration raised
how many heights can be waiting at once from four to about a dozen in steady
state, and the sweep gave each one a full read budget. Head processing is serial,
so one hanging node could spend minutes inside a single head while the chain
moved on.

The sweep shares one budget now. A height it does not reach stays queued for the
next head, which costs a head of delay rather than a hole, and the requeue puts
the oldest first so nothing is starved.

Guard proven by lifting the budget: the sweep then ran to the full length of
every queued read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An idiom review of the file as it now stands, rather than of the last delta.
Five rounds of defect-driven fixes is exactly how a file accumulates
incoherence, and it had.

The retry count was dead. Round 5 replaced a count-based retirement with a
duration and left the field written, passed along, and never compared, plus four
comments still describing the mechanism it belonged to. An editor tuning
re-read depth would have changed a constant that moves nothing.

Removing it made a second fix obvious. matchBlockAttempt and deferHeight took
four and five positional parameters carrying most of deferredRead's fields, two
of them adjacent same-typed timestamps. Transposing arrival and deferredAt
compiles, and it would have measured the inclusion latency and the retry budget
from each other's instant. They take the struct now, so a first read passes no
hand-written zero values at all.

The take-and-clear critical section was duplicated verbatim at two sites that
must change together. takePending owns it again.

blindFetches counted heads that never arrived as well as reads that failed, so
neither its name nor its doc was true. It is blindHeights.

deferred_read_wait labelled its values outcome, which inclusion_outcome already
uses for a disjoint set. One label name meaning two things across two
instruments is the wire hazard Outcome's own type exists to prevent, and the
file states that rule thirty lines above where it broke it. The label is
disposition, and its three values have constants.

requeue dropped a height past the cap silently. deferHeight already holds the
queue at the cap so nothing reaches that branch, but a height vanishing from it
would leave blindHeights unmoved and let a transaction from that block reap as a
verdict about the chain. It records instead.

block_gaps described the arithmetic of a different counter: it adds one per
missed height, while the once-per-gap record lands on block_fetch_errors.

Comment discipline, with the line drawn where the reviewer drew it. A present
fact about the deployment shape stays, because it is the constraint that makes a
shorter re-read budget wrong. The argument with the version that had it
backwards goes, because that is the commit's job and not the code's. Same
treatment for the conservation identity's history and for the empty-array
incident. Two blocks that explain a non-obvious external API stay untouched.

Also: flushDeferred promised a flush and performed an abandon, so it is
recordUnreadAtShutdown, and the call site that still argued the behaviour it no
longer has is gone. refusesTheRun reads as though the reason refuses; it is
isPermanent. reasonNotJSON sat under a comment saying it was not a call failure.
The DNS rationale had been appended to the paragraph about connection resets.
Two nolint directives named a linter this repo does not enable, and their
reasoning survives as plain comments.

stats/doc.go gains the fourth sentinel, the queue's bounds under the lock it
lives behind, the lifecycle branch where a preflight refuses a run, and the two
tests that guard the queue. sender/doc.go gains the sweep bound and the shutdown
boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The commit titled "bound what one head spends re-reading" did not. The check ran
before a read and never bounded the read in progress, so a read starting at
2.999s ran a further ten. Measured 12.8s against a stated 3s, and a worst case of
23s where the shape it replaced was 18s. The bound moved the wrong way in the
commit named for fixing it.

That is not only a cost. The head loop stamps arrival at dequeue, and that value
becomes the inclusion latency sample, so a sweep that overruns writes the
tracker's own backlog into the number the run exists to report.

A re-read now gets whatever is left of the sweep. Which exposed the next thing: a
read this process cut short is not evidence about the node, so that height goes
back in the queue rather than being called a hole. Blaming the serving node for a
deadline sei-load imposed on itself is the same error as blaming the chain for a
block the run never read.

requeue put the unreached tail in front of what the sweep had already re-deferred
and its comment claimed the opposite. A sweep walks oldest first, so what it
re-deferred is older; prepending served the newest first and let the oldest age
out against a budget they were never given a turn under.

The refusal reasons were two lists that had to agree: one deciding whether a
reason ends the run, one turning it into a message. Adding a reason to one and
not the other gave either a silent stall through the retry loop or a refusal
nothing could reach, with no signal from the compiler or a test. One table now.

block_fetch_errors counted a gap once while every other reason counted per
height, so an operator summing it undercounted by the length of every gap. The
watermark still rises once, because the reap only asks whether it rose.

Six guards were missing and one was worse than missing: deleting requeue outright
left the suite green while heights vanished, because the assertion only asked
whether the queue was non-empty and the heights the sweep did read had refilled
it. The whole duration-budget mechanism had no test at all, so it could be
disabled, set to five hundred hours, or restarted on every read without a
failure. Each now has a guard, proven by breaking what it covers.

Also: the latency histogram's count is no longer the included count, since a
matched receipt with an unreadable status still samples, and the comment said
otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The strongest finding of the review, and the one every earlier round walked
past. It was measured: 565 of 1000 transactions reported expired on a chain that
included every one of them, with every hole counter reading zero.

No read fails. No head is missed. No receipt is null. The tracker's head loop is
serial, so when reads take longer than the block interval a backlog builds, and
the block carrying a transaction is opened after the reap already evicted it.
Five rounds hardened every path where a read fails; this is the path where every
read succeeds and the tracker is late. sender/doc.go says expired is a claim
about the chain and the run has no grounds for one about a block it did not
read. At reap time the run had that block in hand and had not opened it.

The registry now knows the highest head taken off the wire and the highest whose
block has been read. A reap that finds them apart cannot say expired, because
the transaction may be sitting in a height the run is holding. A caught-up run
still says expired, which is the point of keeping the two states apart, and that
direction has its own test.

The same backlog corrupted the number this tool exists to report. Arrival was
stamped after the head came off the channel, so every latency sample carried the
queue. Measured at 8.2 seconds of error after 16 seconds of chain at 1.5x block
time, and 43 seconds at 3.75x, against a histogram whose top bucket is 120. The
stamp is taken where the head arrives now, by a step that exists to keep it
there, and head_lag reports the gap. That is the number that separates a chain
taking nothing from a run that could not keep up: both show un-included
transactions, and only this one says which.

A head stream that ends mid-run no longer fails the run. Any error from the
tracker cancelled the whole scope and exited non-zero, so a dropped WebSocket on
a read-only observer turned a good run red and killed the senders, the generator
and the report with it. That is the mirror of the refused-run-exits-zero defect
fixed earlier: this one is the false fail. Tracking stops, every later height is
unread so nothing after it reaps as a chain verdict, and the run finishes.

Also: the WebSocket client was never closed.

Guards proven by breaking what they cover: the backlog rule in both directions,
a dead head stream, a head never counted as received, a head counted only after
its block was read, and a stamp taken late.

The stamping needed the pump extracted to be testable at all. A guard that
drives processHead directly cannot see where Run takes its timestamp, and that
is the third time in this series a guard has named something it did not check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three correctness findings, all mine from the last two commits, all measured by
the reviewer and reproduced here.

The sweep budget was three seconds and one read's budget was ten, so capping a
re-read at whatever the sweep had left capped every re-read at three. A node
answering in four seconds, well inside its own budget, never finished one: cut
short, requeued, cut short again, then retired as though the node were behind. In
the topology where every height is deferred that is every height in the run, and
expired becomes unreachable. A sweep budget is now not smaller than a read's, and
a test pins that relationship rather than the two numbers.

The same class one layer up, found by a test I wrote for something else. The wait
budget was shorter than a sweep, so a sweep could outlive it and retire its own
tail: heights called receipt_node_behind for time this process spent on the
heights ahead of them. The wait budget is now twice the sweep, and that is pinned
too.

Ordering the requeue by age starved the queue. A sweep walks front to back, so
what it re-deferred got a turn and the tail did not, and putting the tail behind
them meant the same front entries consumed every sweep. Measured: three queued
heights, a node holding only the third, and the third retired without the run
ever completing a read of it. Order here is service fairness, and the earlier
change to age order was a mistake I took from a review without testing what it
cost.

A reap could still claim the chain while a height sat queued. The previous commit
compared heads received against heads read, which a queued height passes: it was
received and read once, the node did not have it, and the run is waiting to ask
again. A transaction may be in it.

Two guards were vacuous. One asserted a reap outcome on a tracker whose reap
window was a minute, so nothing was ever old enough to reap; making it real
showed the defect above. One asserted a sweep's bound with a source whose delay
scaled with the constant under test, so shrinking that constant kept the test
green.

The shutdown sweep now raises the attribution watermark without emitting a
failure. Those heights genuinely went unread, so nothing reaped afterwards may
speak for the chain whatever order shutdown runs in; but a healthy run draining a
queue it was always going to drain is not the run going blind, and the
operator-facing counter should not say it was.

Also: a single missed head went unrecorded, since the gap tests used two and
forty-nine. And the guarantee that the first entry of a sweep gets a full read
was dead code once the budgets were ordered correctly.

Eleven guards proven by breaking what they cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A traceability review, the first anyone has run on this work, and it found what
six rounds of correctness review could not: the code is careful and it does not
match its specification.

TOT-022 is a MUST. The run takes its head signal from the same node it reads
status from, because a head carries the raw committed height while a status read
resolves through a watermark behind it. The two disagree even on one node, and
taking them from separate nodes adds peer lag on top, which a node inside its
readiness threshold carries for minutes without reporting unhealthy. The
requirement's own verification row names the failure as "the head signal and the
status read come from different nodes". That is what this code did, with a
comment justifying it on cost grounds, answering a question the requirement did
not ask.

The cost of that violation is most of the machinery built since. The deferred
read queue exists because heights arrived before the reading node held them,
which is the condition TOT-022 forbids creating. It stays for now, because one
node still disagrees with itself across the watermark, but it should be rare
rather than constant.

And it had made expired unreachable a second time. The reap refuses a chain
verdict while a height sits queued, which is right, and it is only safe because
the queue is normally empty. Split the nodes and the queue never empties, so a
run against a healthy chain that took nothing reports "I could not see". That is
the defect of two rounds ago, reintroduced through the topology this change
recommended. A test now drives the single-node steady state and asserts expired
is reachable.

TOT-023 says the run must not read a block older than the deadline it gives a
transaction to reach one. The read budget was a fixed twenty seconds while the
deadline is operator-configurable, so a run reaping at five seconds re-read
heights four times past its own bound. The budget answers to the deadline now.

The traceability mechanism was absent rather than incomplete. One requirement ID
appeared in the whole test suite, in a comment, from the previous PR, while the
repo already does this properly for another feature. Forty-two guards now name
what they cover. Eight requirements are cited by nothing, and that is the point
of doing it: TOT-005, 007, 012 and 019 are the report, TOT-010, 014 and 018 are
the hand-off channel, and all seven belong to later phases. TOT-013 is
contradicted rather than deferred, and it is called out below.

Also: sender/doc.go stated the identity with six terms where the data model has
seven; the README advertised a report carrying committed and reverted, which it
does not; and the duplicate-registration collision is filed under
status_unavailable, which is the closest state the spec defines and not a clean
fit, now said out loud where it happens.

Open against the spec and not fixed here: the preflight refuses a run, and the
spec's design section says nothing in this feature fails a run. Refusing is
probably better than a silently blind run, but it is an unrecorded amendment,
and it makes TOT-013 and SC-008 unreachable by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roughly 750 lines out, 110 in. The tracker drops from 1219 to 884 lines, its
tests from 1224 to 784, and the suite from 20 seconds to 11.

The deferred-read queue, its sweep budget, its requeue, its starvation rule, its
three interacting constants, its shutdown drain and its wait budget derived from
reapAfter all existed for one reason: heads arrived from a node that was not the
one being read, so a height was routinely announced before the reader held it.
Fixing that violation removed the cause and left the apparatus.

The cause is not entirely gone, and that is why a retry stays. seid publishes a
head from Commit while the receipt store's writer is still asynchronous, so a
height answers null for the gap between the two. Our nodes run the pebbledb
receipt store with an async write buffer of a hundred, set in the fleet's own
defaults, so the window is real and bounded by that queue rather than by the
chain. It is one write, not one block, so waiting in place resolves it in
milliseconds where the queue waited for the next head.

Deleting the retry as well was the tempting move and it is wrong. A hole raises
a watermark that only grows, so one hole anywhere inside a transaction's reap
window converts it. At a null rate of five percent, every transaction in a run
reports status_unavailable and expired never fires: the tool loses the one
verdict it exists to deliver, and a run that can never say the chain dropped
anything is not a load test.

The preflight loses its refusal table and most of its classifier. The probe asks
for height 0, which sei-chain answers from a constant ahead of its watermark and
its receipt store, so a healthy EVM RPC cannot fail it and the reason for a
failure cannot change the verdict. That also closes a hole the larger version
had: an endpoint answering not-found to genesis is not a Sei EVM RPC at all, and
it used to be admitted.

deferred_read_wait becomes block_read_wait and loses its disposition label. It
now measures one thing, which is how far a node's watermark trails its own head,
and that is the number nobody has and the one that says whether even the retry
earns its place. Nothing in the platform repo reads either name.

Fourteen tests lost their subject with the code they covered. Three guards
replace them: the gap is waited out rather than written off, the retry gives up
rather than holding the head loop, and expired stays reachable.

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

Run had no coverage. Every ordering fact seven rounds of fixes depend on — the
pump stamping a head's arrival, the head loop reading its block, the reap
deciding what to call a transaction, a dead subscription ending the tracking —
was exercised only by helpers called by hand. That is why the two worst defects
in this change survived seven rounds of review, and why four of the guards
written along the way turned out to assert nothing.

The harness is an in-process WebSocket server speaking eth_subscribe("newHeads"),
so a test drives the real subscription, the real client and the real loop. It
earned its keep immediately: the first version sent a header the go-ethereum
decoder rejects, and the run reported the subscription dying rather than the
header being wrong. Coverage of Run goes from nothing to 77%.

The first defect it exposes fires on every run. The senders and the tracker start
together, and the tracker dials and probes before it reads a block, so every run
accepts transactions during a window in which it is observing nothing. Those
transactions land in blocks the run never opened, and reaped as expired: a claim
about the chain drawn from a period the run did not watch. The registry records
when the run first read a block, and a reap will not speak for the chain about
anything accepted before that.

The second is what a dead subscription does. The reap loop ends with the head
loop, so nothing reaps afterwards, and the registry kept filling from senders
that were still working until everything reported dropped_at_cap. An operator
reads that as a cap to raise rather than a subscription that died. Tracking
stopping now settles everything in flight, and a later registration is answered
rather than stored.

That made the reap's own trackingStopped arm unreachable, which two reviews had
already called dead for a different reason. It is gone; Register is the only
reader now, and the field says so.

The registry's conservation identity omitted status_unavailable, so it could not
see a transaction migrating into that term. It has five terms now.

Guards proven by breaking what they cover: the first-read watermark and its
placement, the drain, the registration path after tracking stops, and the reap
arms in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round eight found that both could be disabled with the suite green, and that is
the over-reporting direction: every un-matched transaction in every run reports
status_unavailable, and nothing says so.

One transaction cannot catch it. The first-read watermark and marking a head
resolved both move a single transaction's verdict the same way, so a test with
one subject cannot tell a working watermark from a dead one. The guard drives Run
with two: one accepted before the run read anything, which must not be blamed on
the chain, and one accepted after and never included, which must be. A third
covers a subscription that is up and has delivered no head, where every other
watermark reads as healthy because the run is trivially caught up with the
nothing it has seen.

The drain reached the metric ledger and not the closing log line, and no test
compared them. That is one transaction counted under two names, which the reap
path has been guarded against for several rounds and the drain path had not.

An ingress error page was retried three hundred and eighty-five times over ten
seconds. rpc.HTTPError renders as "404 Not Found: 404 page not found", so the
substring check read it as a height the node had not reached yet. Deleting the
classifier's typed checks left that substring first to match. The typed checks
are back above it, and a table pins which reasons the read retries: only the one
that means the node will answer differently in a moment.

The package doc still described the deleted queue, named four tests that no
longer exist, and said two goroutines drive the run where there are three. It
also claimed everything in flight at shutdown is inflight_at_shutdown, which
stopped being true when a dead stream started settling them.

Guards proven by breaking what they cover: the watermark unset, its arm removed,
heads never resolved, the drain's counter, and the error page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TOT-023. A transaction past reapAfter counts as expired whatever a later
block says, so reading that block answers a question the run has closed and
can only reopen a settled outcome.

Each height's read now gets what is left of its own deadline, capped by
readTimeout, and a height with nothing left costs no request. Budgeting the
remainder rather than a fresh window is what makes the oldest height the run
ever asks for exactly reapAfter: a window measured from now would let a read
starting just inside the deadline finish outside it, and the retention floor
this puts under the tracking node would then move with a constant this file
owns.

It also drains a backlog instead of deepening one. A tracker falling behind
gives each later head a smaller budget, and once a head has none left it
costs no request at all.

readTimeout stops being the bound. Its own comment said it "should stay below
the run's reap deadline", which held at the 30s default and not at
--inclusion-reap-after=5s. An assumption a flag can break is not a bound.

Test fixtures move off a fixed Unix second, because matchBlock now measures a
head's age against the real clock and a head stamped in 1970 is one the
tracker is decades behind on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run report named no outcome at all. Every count existed in the collector
and none of it reached the operator, so a run whose transactions all reverted
printed the same send rate and the same latency distribution as a run that
did everything it claimed.

The new section prints three layers and never totals across them. Committed
and reverted are what the chain did. Expired and the two drop counts are what
this run did to its own transactions. StatusUnavailable and
InflightAtShutdown are what it did not observe (TOT-012, TOT-019).

Three arms, and the difference between two of them is the point. A run that
tried to measure and failed prints its zeros under a warning, because an
absent number reads as zero (TOT-013). A run with --track-receipts off prints
no counts at all, because "committed 0" for a run that never asked
manufactures a chain finding out of a flag setting.

The counts come from the collector, not from InclusionSummary.Included, which
adds committed and reverted together. That conflation is the defect this
feature removes.

main reads the inclusion summary before printing rather than after. The
report could not name an outcome while it ran first. The old closing log line
goes with it: it printed the conflated included= and would now contradict the
report on the same stdout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every guard broken on purpose first: an all-expired run mislabelled as a
measurement failure, an untracked run printing counts, the revert share taken
over accepted rather than executed, and the status_unavailable row dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
readTimeout stopped being the bound in the commit before this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TOT-005 through TOT-008. Reporting one ratio hides whichever layer it divides
away, so the run states both: committed over every send it attempted, and
committed over the sends an endpoint accepted.

The first answers what the profile asked for. A run whose endpoint refuses
half shows a halved number rather than a healthy one measured over the
survivors. The second isolates chain execution, which is the number to read
when the RPC layer is not under test. The gap between them is the rejection
share, so the report names that too.

An indeterminate status counts in the denominator and never in the numerator.
Counting it in the numerator reports a success nobody saw; dropping it from
the denominator reports a healthy ratio over the survivors, which is the
defect this feature removes one layer up.

Every operation carries its own ledger. The run-level total cannot separate a
revert rate spread evenly across operations from one concentrated in a single
call, and those point at different things: the chain, or the workload. A run
reporting 46% goodput where one operation sits at 0% and another at 98% now
says so.

It prints as a second line under each operation rather than widening the
first, so anything parsing the existing format keeps working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TOT-010, TOT-014, TOT-018. The sender called Register on the goroutine that
had just completed a send, and Register took the registry lock. The reap loop
holds that same lock while it walks every in-flight transaction, so a sweep
landed in the latency this package reports.

Submit is a non-blocking send into a channel. A dedicated goroutine drains it
and does the admission. A full channel drops the transaction and counts the
drop: a hand-off that blocks brings back the stall this removes, and one that
drops in silence leaves an accepted transaction with no terminal state.

The drain performs no chain read, which is what makes the depth derivable
rather than guessed. A drain sharing the head loop would inherit that loop's
block read, and no depth absorbs a multi-second stall at a few thousand
transactions per second. Admission alone waits only as long as the registry
lock is held.

The depth follows from the configured send rate at one second of headroom,
with a floor for a profile that sets no rate. That is four orders of magnitude
above what the drain actually stalls for, and shallow enough that a drain
which cannot keep up says so through the drop count rather than hiding in a
deep queue.

Tests call admit directly where they want deterministic admission, which is
what they were written to check. The hand-off has its own tests.

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

cursor Bot commented Aug 29, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes hot-path inclusion handoff and outcome accounting (new terminal state and summary fields); mistakes could mis-attribute drops vs unavailability or skew conservation metrics under load or shutdown.

Overview
Replaces synchronous Register with a non-blocking Submit path so successful sends no longer take the inclusion registry lock on the send goroutine (avoiding stalls behind reap sweeps).

Senders call Submit, which enqueues on a buffered channel sized from configured TPS (~one second of headroom, floor 1024) or drops and counts dropped_at_handoff when full. A drain goroutine admits into the registry via internal admit without chain reads. Run starts the drain before dial/preflight so startup sends are not misclassified as handoff drops.

Shutdown / dead head stream: closing admission routes late handoffs to status_unavailable (not queue overflow); queued items are settled on drain exit. InclusionSummary and docs now surface DroppedAtHandoff; the closing warning text reflects receipt failures and post-tracking handoffs.

Extensive handoff_test.go and test updates (admit where tests bypass the channel); main passes TPS into NewInclusionTracker.

Reviewed by Cursor Bugbot for commit a23da27. Bugbot is set up for automated code reviews on this repo. Configure here.

golangci-lint runs staticcheck with the QF checks on, which bare staticcheck
does not, so these three only appeared under make lint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread stats/inclusion_tracker.go Outdated
Comment thread stats/inclusion_tracker.go
Comment thread stats/inclusion_tracker.go Outdated
bdchatham and others added 21 commits September 1, 2026 13:30
…come-report

# Conflicts:
#	config/settings.go
#	config/settings_reap_test.go
#	stats/inclusion_outcome_test.go
#	stats/inclusion_tracker.go
Two reviews disagreed about the skip, and both were right about their half.
Skipping at reapAfter discards receipts for a transaction the reaper has not
swept yet, turning a committed into status_unavailable. Reading out to twice
that reads blocks nothing is waiting on, which TOT-023 forbids.

The deadline was never the right test. A block whose head arrived at some
moment can only carry transactions registered before it, so the exact question
is whether any live entry is that old. Asking the registry answers it without
depending on the reaper's tick, so neither failure is reachable and the
comment is true without qualification.

evictionBound goes with it, and the retention floor is reapAfter again rather
than double. The skip warning names the bound that actually fires.

The scan runs only once a height is already past its deadline, which happens
only while the tracker is behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Register becomes admit here, and the test wants deterministic admission.

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

The gate added a moment ago handed a past-deadline read the full readTimeout,
measured from now. That is the thing readBudget was changed to stop: the read
outlives the deadline it belongs to, and a backlog pays the ceiling per height
rather than draining. staleReadBudget measures the remainder the same way
readBudget does, floored so the attempt is worth making.

The test for that gate never reached it. It stamped the head two seconds in the
future, so the budget stayed positive and the ordinary path ran. Both times are
in the past and ordered now, with an assertion that the budget really is under
the floor before the read, so the branch cannot go unexercised again.

The mutation pass missed it for the same reason: removing the whole gate broke
the ordinary path too, which is what the test was actually covering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An idiom review found eight, and the doc-attribution ones are real defects
rather than style: godoc gave skipPastDeadline's comment to the function above
it and left skipPastDeadline undocumented, and placed carried unaccounted's.
Both verified with go doc rather than by reading.

readBudget still claimed the oldest height the run asks for is exactly
reapAfter. matchBlock reopens a height past its deadline while something waits
on it, so the bound is near twice that. The claim names an operational
contract an operator sizes a node against, so a stale one is worse than none.

The cross-package assertion moves to the toolchain's own static-assert form.
The array-length form ties its failure to the constants' magnitude and the
target word size as well as to the ordering: verified that it overflows int on
a 386 build at a thirty-second floor, where the conversion form does not, and
that the conversion form still fails on an inversion.

MinInclusionReapAfter's first sentence called it the shortest accepted
deadline while Validate and its test both exclude it. It is the longest
rejected one.

Three comments narrated edits rather than stating the present, one of them in
the first person.

ExecutionOutcomes was missing from the package doc's type list, and its own
documented invariant, that Tracked is derived and never set by hand, had
nothing holding it.

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

# Conflicts:
#	stats/inclusion_outcome_test.go
… one try

Two defects in the past-deadline skip, one of them mine and one from review.

skipPastDeadline incremented blindHeights. A hole says the run could not see a
height, and reap files every transaction in flight across one as
status_unavailable. But the gate above it has already proved nothing live
could be in that block, so the only transactions the hole reached were ones
that provably could not have been in it. Measured: a transaction registered
after the skipped head reported status_unavailable when the run had watched it
correctly the whole time. It reports expired now.

staleReadBudget measured a remainder that is provably already spent. The
branch runs only once the head's own deadline has passed, and the transaction
waiting on it is older still, so the max always won and every reopened height
got exactly minReadBudget. The code says that value directly, with the reason
a remainder cannot help.

The gate stays. Removing it was measured too: a transaction registered before
the skipped head then reports expired for a block that provably contained it,
which is a false claim about the chain and worse than what it replaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Register becomes admit here, and the test wants deterministic admission.

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

Three from review, and the first is a real defect.

The reopen branch fell through to clearPastDeadline, which logs that the
tracker caught up and clears the flag. A reopened height is past its deadline
by construction, so that states the opposite of what happened. Because the
flag is edge-triggered it also re-armed the warning, so a sustained backlog
alternated between the two every sweep: entries age past the deadline between
sweeps, a head reopens and clears, the sweep drains them, and the next head
warns again.

oldestLiveBefore computed a minimum nobody read. The caller wants the answer,
not the time, so it returns on the first match and is named for the question
it asks. It runs on the head loop holding the registry lock, which Register
and reap both wait on, and the registry holds tens of thousands of entries at
load, so the full scan was work the question does not need.

One paragraph in matchBlock survived the rewrite above it and still said the
skip records a hole, which is the opposite of what skipPastDeadline now
documents about itself.

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

# Conflicts:
#	stats/execution_outcomes.go
#	stats/execution_outcomes_test.go
#	stats/logger.go
Base automatically changed from brandon2/plt-1079-goodput-ratios to main September 1, 2026 22:12
bdchatham and others added 2 commits September 1, 2026 15:34
…doff-channel

# Conflicts:
#	stats/execution_outcomes.go
#	stats/execution_outcomes_test.go
#	stats/logger.go
…eans

Four from review.

The run-level DroppedAtHandoff overwrote the per-operation sum with the
tracker's tally, on a comment claiming the tracker is the only place holding
it. Submit's drop leg calls report, so the collector holds it too. The two
move in lockstep, which is why overwriting is the loss: a future divergence
gets absorbed instead of surfacing through the residual. Resolved by taking
main's side of the merge, which never had the overwrite.

inclusionState.droppedAtHandoff was declared, read once, and never written.
Two homes for one count under a plus sign reads as deliberate, and the next
contributor to increment it would do so under the registry lock, which is the
stall this change removes.

Summary added the queue depth to its own tally and nowhere else, so the report
ledger was short by the buffer depth and the residual read the gap as a
counting bug. Those transactions reached no terminal state anywhere, which is
what unaccounted means, so the residual is where they belong.

StatusUnavailable now carries hand-offs that were never in flight and never
had a receipt read, but its doc and the closing log line still said a receipt
read failed. On a mid-run head-stream death that names the receipt endpoint
for a dead subscription, which is the wrong-cause substitution Submit's own
closed branch exists to avoid.

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

Copy link
Copy Markdown
Contributor Author

All four addressed.

The overwrite. You are right that my justification was factually wrong — Submit's drop leg calls report, so the collector holds the count too. Your reasoning for why overwriting is the loss even when the two agree is the part worth keeping: a future divergence gets absorbed instead of surfacing through the residual. It is gone, and the loop's sum stands.

The dead field. Confirmed: inclusionState.droppedAtHandoff was declared, read once at Summary, and never written. Removed, with its doc moved onto handoffDropped beside the "atomics rather than fields in inclusionState" rationale. The hazard you named is the reason it was worth doing rather than leaving — the next contributor to increment it would take the registry lock, which is the stall this PR removes, and Summary would keep adding either way so nothing would fail.

The ledger split. queued reached the tally and nothing else, so the report was short by the buffer depth and unaccounted() read the gap as a counting bug. I dropped it from Summary rather than plumbing it to the collector: those transactions reached no terminal state anywhere, which is exactly what the residual means, so that is where they belong. TestQueuedAtShutdownReadsAsUnaccounted replaces the test that asserted the split.

The wording. Taken, including the log line. On a mid-run head-stream death the count is dominated by hand-offs, so "%d txs were in flight while a receipt read failed" pointed an operator at the receipt endpoint for a dead subscription. It now says the run could not resolve them and names both causes.

mutation caught
Summary counts the queue again, splitting the ledgers yes
the hand-off drop count is lost yes

Merge conflict cleared too. Main had #79 and #82 since this branched, and this time main was the newer side rather than the branch, so I took main throughout and verified every Submit/drainLoop/admissionClosed symbol survived.

make lint, full suite and -race clean. Cascaded to #81.

@bdchatham
bdchatham enabled auto-merge (squash) September 1, 2026 22:39
@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

@bdchatham
bdchatham merged commit 99f0a2f into main Sep 1, 2026
9 checks passed
@bdchatham
bdchatham deleted the brandon2/plt-1080-handoff-channel branch September 1, 2026 22:41

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four unresolved findings from the previous review are genuinely fixed: the logger.go overwrite is gone, Summary no longer counts the queue depth into the tracker-only tally, the dead inclusionState.droppedAtHandoff field is removed, and both the StatusUnavailable doc and the closing log line now name the wider cause. What remains is doc/code drift: closeAdmission's own comment still claims Summary counts the buffer, Submit's "never blocks" still overstates what the fallback legs do, and the hand-off depth's derivation reads backwards for the default unthrottled profile.

Findings: 0 blocking | 4 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] RunSummary (stats/run_summary.go) still has no DroppedAtHandoff field, so main.go:436-440 populates every other term of the conservation identity from InclusionSummary and omits the one this PR makes producible; its StatusUnavailable doc (line ~40) also still says "registered txs that were in flight while a receipt read failed", which is no longer the whole story now that post-drain hand-offs land there. Only inflight_at_shutdown is emitted as a gauge today so no metric is wrong yet, but the struct that mirrors the identity no longer closes and the gap surfaces when the remaining gauges land (TODO(PLT-467), stats/run_summary.go:33). Codex flags the same thing.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

// The order matters. Closing first means a sender racing this call takes the
// unavailable path instead of adding to a queue nobody will read. Settling
// second empties what was already there. A send that wins the race anyway is
// still accounted for: Summary counts whatever remains in the buffer, so the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This doc no longer matches Summary. The + queued term was removed this revision (line 1240 is now s.statusUnavailable + s.duplicates + t.handoffUnavailable.Load()), and TestQueuedAtShutdownReadsAsUnaccounted asserts exactly the opposite of what the comment claims: two transactions sitting in submit, Summary().StatusUnavailable == 0. So a sender that wins the race described here is not accounted for and the identity does not close — it surfaces as a non-zero unaccounted() residual, which execution_outcomes.go:114 calls "the only figure in the report that catches a counting bug".

Dropping the tracker-only increment was the right call (it was the ledger split flagged last round), but the comment should now say what the code does: the residual is where such a transaction shows up, deliberately.

The race itself is also closable rather than documented away — a re-check after the successful enqueue would do it:

case t.submit <- tx:
	select {
	case <-t.admissionClosed:
		t.closeAdmission(ctx) // idempotent; re-drains what we may have just added
	default:
	}

closeOnce already makes closeAdmission safe to call from Submit, and the second drain pass costs nothing on the happy path because the outer select never fires. Codex rates this a blocker; the window is narrow and the outcome is an honestly-labelled residual rather than a wrong number, so a suggestion seems right — but the comment asserting the identity closes should not stay.

// Submit hands ownership of tx's InclusionTime to the tracker. Call it only for
// successful sends, at send-completion, so registered is a subset of succeeded.
//
// It never blocks. The sender calls this on the goroutine that just completed a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Raised as a file-level note last round and still present, and Codex flags it too: "It never blocks" is true of the registry lock but not of the collector lock. Both fallback legs call t.report (lines 319 and 328), which takes c.mu via Collector.RecordOutcome (line 889-893). The admissionClosed leg is the one that matters — after the head stream dies it fires for every send for the rest of the run, not once.

The code is defensible as-is: the send path already takes the collector mutex unconditionally on every send (sender/eth_client.go:152RecordTransaction), so this is not the stall the PR set out to remove. It is the wording that overpromises. Neither TestSubmitDoesNotBlockWhenTheQueueIsFull nor TestSubmitDoesNotBlockBehindTheRegistryLock holds the collector lock, so nothing pins the claim either. "It never waits on the registry lock" would be accurate and would still say what the hand-off exists for.

// small enough that a drain which genuinely cannot keep up says so through the
// drop count instead of hiding in a deep queue.
//
// The floor covers a profile with no rate set, where TPS arrives as zero.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The floor's rationale reads backwards for the default profile. TPS: 0.0 is the config default (config/settings.go:133), and main.go:254 treats zero as no rate limiter at all — an unthrottled run, i.e. the highest send rate the process can produce. So "a profile with no rate set" is not a low-rate fallback; it is precisely the case where one second of send is largest, and it gets the smallest queue (1024) plus a depth that is a constant rather than something derived, which is what the function doc says it exists to avoid.

In practice 1024 is probably ample — admission stalls only for the registry lock — but two things follow. TestTheHandoffDepthFollowsTheSendRate documents handoffDepth(0) == 1024 as "a profile with no rate set", which reinforces the wrong reading, and an unthrottled run is exactly where dropped_at_handoff first fires outside a deliberate overload test, which is the stated un-defer condition for D-2. Worth either deriving the unthrottled floor from something real (inclusionRegistryCap already reaches for maxInFlight * 4 when TPS is zero) or saying plainly that zero means unthrottled and 1024 is a judgement call for that case.

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