Let the target decide when a bootstrap checkpoint cannot be split further - #118
Conversation
|
All three blockers were real and are fixed in 703d9da. Each now has a regression test that I confirmed fails with the bug reintroduced. 1 — sentinel fired on unrelated errors. Correct and embarrassing: the gate sat outside the size-failure branch and keyed off 2 — a genuine 413 could never reach the sentinel. Also correct. Terminal now covers both verdicts the target can give: a rejection it actually sent ( 3 — the relaxed ceiling leaked. Fixed by making it a per-attempt Correctness findings, all taken:
One thing I did not do: your suggestion that a test should exercise the projection branch of Full suite and golangci-lint green. |
b06c763 to
21f7e13
Compare
|
Rebased onto main (post-#117) and addressed the adversarial review. Head is now Rebase note: main had moved 8 commits including #117, which touches the same The review's core finding was coverage, not correctness — three mutants survived the entire suite. All three now fail:
Worth flagging for anyone else mutation-testing this file: removing that guard from the condition makes the variable unused, so the mutant doesn't compile, and a grep for Also fixed the three comments still describing the retry this design removed — including a dangling paragraph about the deleted Full suite + golangci-lint green. |
…ther Rebased onto main after #117 (Soph's batched-cutover marker fix) and squashed: the five original commits were an iterative refinement of the same code, and resolving their conflicts one at a time against the moved main produced worse results than resolving the net change once. The review narrative lives in the PR discussion. Once subdivision bottoms out — checkpoints split BETWEEN commits, so at one commit per gap there is nothing left to split — a single commit whose pack exceeded git-sync's own budget failed the run. That budget is self-imposed and far below what the target accepts: TargetMaxPack defaults to 512 MiB while the target announces 10 GiB, and autoTargetMaxPackBytes derives 5 GiB from that announcement and then discards it for being larger than the default. So the run gave up against a number git-sync chose, having never asked the server. The mirror that motivated this dies exactly here. The batching budget stays small on purpose — it bounds the waste of a doomed push and makes the temp ref advance often, both of which require a smaller pack to be possible. On a one-commit gap neither is, so the ceiling for that push is the target's announced limit instead, chosen before the push rather than after a doomed attempt. This costs nothing: a ceiling is an abort threshold, not an upload size, so a pack that fits sends identical bytes either way — and it avoids fetching an indivisible multi-GiB commit twice. That gives the failure a verdict worth classifying. An abort against our own budget stays retryable: a larger budget or a raised server limit could still mirror the repo. A checkpoint that is indivisible AND refused by the target — a parsed body-limit rejection, or an attempt at its announced limit that still overshot — returns ErrCheckpointExceedsTargetLimit, aliased into the root package so the mirror worker can match it with errors.Is and stop redelivering an identical pack ten times. A deadline (408/504) is availability, not size, so it stays retryable; classifying it permanent would let one target rolling restart permafail every large bootstrap in flight. Also stops discarding the bootstrap Result when Execute fails. The route facts (RelayMode, RelayReason, batch counts, temp refs, Plans) are set before anything can fail and describe the route rather than the outcome; zeroing them is why a failed sync could report its strategy only when it succeeded — the gap that made ENT-2054 a source read instead of a log query. Batching is recorded before checkpoint planning, whose commit-graph fetch is the likeliest failure for exactly the repos that batch, and unstable.Client.Bootstrap no longer throws the result away either. budgetFromObservation guards against escalating past a cutoff MEASURED from bytes actually sent (a middlebox that cuts without announcing) rather than one the target stated. It lives beside selfImposedBudget, which spans branches: provenance has to travel with the value, or a later branch escalates past a limit an earlier one demonstrated. It has no test coverage — a fixture I built did not bite when the guard was removed, and I deleted it rather than keep a test passing for reasons I could not explain. Verified on the rebased tree: full suite and golangci-lint green, Soph's cutover tests pass alongside these, and removing the escalation or treating a deadline as a size verdict each still break the new tests. Entire-Checkpoint: 01M1C4BS40PK95XMCB8MF5YH3Q
…f guard The rewrite had no correctness bug, but mutation testing found it had deleted the only coverage of its own central claim. Three mutants survived the whole suite; all three now fail. The gate itself was untested: dropping `isIndivisibleCheckpoint` from the ceiling decision left every test green, so nothing pinned that ONLY an unsplittable checkpoint escalates. That mutant pushes every checkpoint at the announced ceiling with no margin and no projection, silently destroying the bound on wasted upload that is the entire reason TargetMaxPack is small. The deleted budget-leak test had been the closest thing to coverage. Replaced with a divisible span that must still abort at the small budget. The >= boundary was untested too. An in-batching rejection ratchets the budget down to the announced limit, leaving them equal, and escalating there still sheds the 95% margin and the projection — a pack sized inside that last 5% would otherwise abort on every delivery forever. Now pinned by a pack at 97.8% of an announced limit the run learns from its own one-shot rejection. And the measured-cutoff guard is covered at last. The reviewer worked out why my earlier fixture could not bite: on a one-commit chain the guard is structurally unreachable, because any failure that would set it ends the run on that same checkpoint and no later iteration remains to read it — and a pusher that never drains leaves sentBytes at 0, so it is never set at all. Reaching it needs two commits: a divisible span to take the observation, then an indivisible one to consult it. Their fixture, with that reasoning recorded in the test so the next person does not repeat the dead end. Note for anyone mutation-testing this file: removing the guard from the condition makes the variable unused, so that mutant does not compile and a grep for test failures reports a false negative. Keep it referenced. Also fixes three comments left describing the retry this design removed — including a dangling paragraph about the deleted relaxedBudget fused onto the new comment — and emits the announced-ceiling notice once per branch rather than per push, since the ceiling is now chosen per push and an uneven-gap stretch would otherwise repeat it for packs nowhere near either number. The structured log still records every push. Entire-Checkpoint: 01M1C4M6DRCWHBCR4MNJ0X95YT
0cf1d73 to
3e55d75
Compare
|
bugbot run |
subdivideToFactor splits EVERY remaining gap, so a splittable gap later in the branch grows the checkpoint list even when the current span is already one commit. The growth branch ran before classification, so that growth triggered a retry — and a one-commit gap has no midpoint to gain, so the retry re-fetched and re-pushed a byte-identical pack. Repeated once per later split, that is the same doomed upload several times in one run, and on a deadline at the announced ceiling each repeat is multi-GiB. Subdividing is now attempted only when the current span can actually shrink. When it cannot, there is nothing to retry for: fall through and classify, which either returns the target's size verdict or a retryable error whose redelivery re-plans from the temp ref. Pinned by a 5-commit chain planned into 4 batches, which yields gaps of 1,1,1,2 — the first checkpoint indivisible while the last gap can still split — failing on a deadline so the old code took the growth path. Two pushes of the identical pack before, one after. Entire-Checkpoint: 01M1C6YZ80MHFE69RAW9ZJPCP2
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit ca89abb. Configure here.
…tion A target that drains the body and then times out — GitHub's 408 shape — leaves abortedEarly false and sentBytes above zero, so the budget ratcheted to those bytes AND recorded itself as a measured server cutoff. That flag gates escalation and is cleared only by a later parseable 413, so one deadline disabled the feature for the remainder of the run: exactly the wrong outcome on the flaky multi-GiB targets this path exists to serve. It also contradicted the design's own principle. Classification already treats a deadline as availability rather than size; provenance was treating the same error as measured size evidence. The ratchet stays — smaller packs genuinely do finish inside the window, so the smaller budget is useful information about time — but it no longer masquerades as a size limit. Pinned by a run that takes a one-shot 413 announcing 1 MiB, then a drain-then-408 on a divisible span, and must still escalate an indivisible span afterwards. Without the fix it fails with the same "aborted early: projected to exceed target body limit" the reviewer's probe produced. The pre-flight subdivide had the same asymmetry the previous commit fixed after a failed push: subdivideCheckpoints splits every remaining gap, so a splittable later gap grew the list and re-planned an identical checkpoint even for a span that cannot shrink. Cheap there — a 12-byte header read, then Close — but the same shape, so it is closed the same way, which also sidesteps comparing the estimate against TargetMaxPack rather than the ceiling an indivisible span is actually pushed at. Docs: the sentinel's comment claimed a Bootstrap method the root package does not have (it is unstable.Client.Bootstrap, and that comment is the mirror worker's contract); Plan, Sync and Replicate now document that the result is populated on error, what may be read from it, and that a validation or config failure still returns a zero result; and a duplicated doc comment from the rebase is removed. Entire-Checkpoint: 01M1CG7DAC6WEPQGWG55D25GN4
|
bugbot run |
The previous commit stopped a deadline from CLAIMING a measured cutoff, but wrote the answer unconditionally — so a deadline that ratcheted the budget also assigned false, erasing a cutoff an earlier unparseable size rejection had recorded. Escalation would then jump past a limit the server had already demonstrated, and an abort at that ceiling is classified permanent: a flaky target turned into a false permanent failure. Strictly worse than the bug it replaced. The rule is now a named function rather than an inline expression, because getting it wrong in either direction is costly and the two directions are easy to conflate. A deadline preserves whatever was known; a parsed limit means the target stated its own bound and supersedes any measurement; anything else that ratcheted the budget did so from observed bytes, which is a measurement. Extracting it also made it testable. Two fixtures aimed at this through Execute were vacuous — the runs never reached an escalation decision, so they passed with the guard removed — and I deleted them rather than keep tests that prove nothing. The table test on the function covers all four cases and fails when the deadline branch is removed. Entire-Checkpoint: 01M1CMYZYQ31YWA5NR9PG91CGT
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit fcd1aa2. Configure here.
…behaviour Skipping checkPackSizeAndSubdivide for an unsplittable span also skipped the only thing that parses the pack header, so packObjectCount fell to 0 on exactly the push this feature exists for: object_count and estimated_bytes logged as zero on both lines, and calibrateBytesPerObject got a zero denominator. The calibration loss was inert — an indivisible span never takes the growth path now, so no later iteration consumes it — but the logs are the ones an operator reads when a giant commit stalls. The call is made unconditionally again; the callback declines instead. Same outcome for subdivision, header still parsed, counts still real. The skip was also unpinned: removing it passed the entire suite, which stands out in a change whose every other claim is mutation-verified. Now pinned by fetch count — 5 commits into 4 batches gives gaps of 1,1,1,2, and a header declaring 200 objects makes the estimate exceed the budget so the pre-flight actually fires. Nine fetches with the decline, eleven without: each wasted fetch is an indivisible span re-planned before pushing. Docs: the sentinel's comment had a 107-character line in a block that wraps at 79; observedSubdivisionFactor's doc block sat orphaned above a different function and had started documenting nextBudgetProvenance, so it moves down to the function it describes; and the client comment claimed Counts describe attempted work when the error path does not populate them at all — Applied is 0 even where refs were pushed before the failure. That comment is the mirror worker's contract, so it now says to read Refs and ignore Counts. Entire-Checkpoint: 01M1DY4KRMG0JM8ZA6NC56Z3AZ
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit fcf98d1. Configure here.
Entire-Checkpoint: 01M1DZSQFYVETZC6XCV1AE6YAN
The changelog claimed callers could "distinguish a failed relay from a failed bootstrap". The relay half is false. incremental.Execute returns a zero Result on every error path — including push target refs, which fails after the relay decision — and the syncer returns before copying incResult's relay facts anyway, so a failed relay reports an empty TransferMode, indistinguishable from a pre-execution failure. Only the bootstrap route was fixed. The entry now says that, and names the gap rather than implying it away; the relay path is worth the same treatment but needs its own tests. "errors returned after planning has begun" was similarly broad: sixteen error paths in the syncer still return a zero result. Also: the Added entry re-introduced the ambiguous "Bootstrap" that this same commit range corrected in errors.go — the root package has no such method, it is unstable.Client.Bootstrap. The claim that cancellation errors keep their classification is removed: nothing classifies cancellation, and naming it beside the deadline implies handling that does not exist. The deadline half is real and tested. Unreleased now runs Added -> Changed -> Fixed, matching every released block; the new Added section had been appended below Fixed. And the sentinel's doc paragraph is reflowed rather than left ragged after the earlier line-length fix. Entire-Checkpoint: 01M1EK3KNW2X1HYE8VVF91YJ3S
Follow-up to #116 / ENT-2054, fixing the failure that landing #116 exposed in production. Tracked in ENT-2060.
Problem
Batched bootstrap pushes history in chunks split between commits. Once every gap is one commit there is nothing left to split, and a single commit whose pack exceeded the budget failed the whole run.
The budget it failed against was our own, and 20× stricter than the target's.
TargetMaxPackdefaults to 512 MiB;autoTargetMaxPackBytesderives 5 GiB from the target's announced 10 GiB limit and then discards it for being larger than that default. The observer then aborts at 95% of the 512 MiB. So the run gave up on a pack the target would likely have accepted, having never asked it.Seen in production on a >10 GiB mirror once #116 got it onto the bootstrap route:
Zero batches landed, and every redelivery repeated it — a full multi-GiB source fetch each time.
Solution
The abort budget for a span you cannot split is the target's number, not ours.
The small budget stays for planning: it bounds the waste of a doomed push and makes the temp ref advance often — both of which need a smaller pack to be possible. On a one-commit gap neither is, so that push uses the target's announced limit instead, decided before the push rather than after a doomed attempt.
This costs no extra bytes — a ceiling is an abort threshold, not an upload size, so a pack that fits sends the same bytes either way — and it avoids fetching an indivisible multi-GiB commit twice. At that ceiling there is no 95% margin and no projection: an early cut would invent a rejection the target never issued.
That turns the failure into something classifiable:
ErrCheckpointExceedsTargetLimitThe sentinel is aliased into the root package so the mirror worker can match it with
errors.Isand stop redelivering an identical pack ten times. It is also the evidence that would justify object-level splitting — the only remedy left for a genuinely oversized commit, and something nobody should design until a repo proves it is in that state.Two guards keep this honest:
nextBudgetProvenance, because both directions are costly and easy to conflate.Subdividing only happens when the current span can shrink.
subdivideToFactorandsubdivideCheckpointssplit every remaining gap, so a splittable gap later in the branch used to grow the checkpoint list even for a one-commit span — and the retry then re-fetched and re-pushed a byte-identical pack, once per later split. Both the post-failure path and the pre-flight estimate now decline instead. The pre-flight declines rather than being skipped, so the pack header is still parsed andobject_countstays real in the logs an operator reads when a giant commit stalls.Also here
bootstrapWithInputsreturnedResult{}on error, discardingRelayMode,RelayReason, batch counts and temp refs. Those describe the route, not the outcome, and are set before anything can fail — zeroing them is why a failed sync could report its strategy only when it succeeded, the gap that made ENT-2054 a source read instead of a log query. Batching is now recorded before checkpoint planning (whose commit-graph fetch is the likeliest failure for exactly the repos that batch), andunstable.Client.Bootstrapno longer throws the result away either.Scope worth stating: this covers the bootstrap route only. A failed incremental or materialized relay still reports an empty transfer mode —
incremental.Executereturns a zero result on every error path, and the syncer returns before copying its relay facts — and most other syncer error paths still return a zero result. Same blind spot, one strategy over; it needs its own tests and is filed rather than bundled here.Tests
Each mutation-verified to fail when the behaviour it names is removed:
>=boundary, where an in-batching rejection leaves budget and announced limit equal and escalating still sheds the marginAnnouncedTargetLimitcapture sites — the feature's only production sources, since nothing configures itTwo fixtures aimed at the provenance guard through
Executeturned out vacuous — the runs never reached an escalation decision, so they passed with the guard removed. They were deleted rather than kept, and the rule was extracted into a function so it could be tested directly.Rollout — please read before merging
nodo/ent-2060-worker-permanent-checkpointin mirror-pipeline — sentinel classification, failure-route reporting on spans and logs, and the pin — currently pinned at this branch's head and needing a re-point at the merge commit.lookupGitHubRepoSizeKBsends its api.github.com request unauthenticated, so it 404s on private repos; a GitHub-kind repo entering batching via that preflight never sees a rejection, so never learns an announced limit.Notes
🤖 Generated with Claude Code
https://claude.ai/code/session_01AriFousNMM5gxkW7Akq1SW