Skip to content

Do not let a docs-only merge cancel a code deploy - #258

Merged
tonyalaribe merged 2 commits into
masterfrom
fix/deploy-ignores-docs-supersede
Sep 12, 2026
Merged

tonyalaribe merged 2 commits into
masterfrom
fix/deploy-ignores-docs-supersede

Conversation

@tonyalaribe

Copy link
Copy Markdown
Contributor

CI/deploy tooling only. No crate source change — nothing here can alter the running binary's behaviour.

The bug, observed in production

#251 merged at 01:49 and its Build and Deploy run went green, yet production kept the previous image. The log: A newer master commit superseded this rollout; production is unchanged. The superseding commit was #254 — documentation only.

run.py compares HEAD against refs/heads/master and defers whenever they differ. That is right for a real code push, but a docs commit cannot change the image; docs and bench/ are in deploy.yml's paths-ignore precisely for that reason, so they never start a rollout of their own — and they cancel the one in flight. Silent in both directions: green job, no run for the docs push, nothing reporting that merged code is not running. It stranded #251 for half an hour and was caught only by checking the live digest by hand.

The change

The supersede check now asks whether the newer commits could change the image at all, and defers only when they could. It fails closed: if the newer commits cannot be fetched or inspected, it returns the previous behaviour.

Verification

  • Red/green: with only_undeployable forced to the old behaviour, all four docs-only cases fail; restored, all tests pass.
  • Covers both directions (docs-only must not supersede, code must still supersede), a mixed docs+code diff, and an empty diff.
  • test_ignore_list_matches_the_workflow fails if run.py's list and deploy.yml's paths-ignore ever drift — otherwise that drift would be silent, and the workflow would skip a path this still defers on.
  • Wired into make ci-signoff beside the existing test_lease.py.

Parsed with a regex rather than yaml.safe_load so the check needs no third-party module.

Suggest merging when convenient rather than mid-backfill — merging restarts production, and the Sep 1–7 index backfill is currently converging.

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown

Review

Good fix for a real production gap, and the accompanying test suite is thorough (docs-only, code, mixed, empty-diff, and drift-detection cases). Three things worth addressing before merge:

1. only_undeployable crashes instead of failing closed when master is None

scripts/deploy/run.py:50lease.remote('refs/heads/master') (lease.py:34-36) returns None if ls-remote comes back empty (e.g. the master ref is momentarily unreadable). main() doesn't guard against that before calling only_undeployable(lease, current, master), which then does lease.git('fetch', '--quiet', '--depth=50', 'origin', None). That passes None into subprocess.run's args list and raises a TypeError before any git process starts, so it isn't caught by except subprocess.CalledProcessError — it propagates out of main() instead of printing "A newer master commit superseded this rollout" and returning gracefully like the docstring promises. This is an unlikely edge case (master effectively always resolves), but since the whole point of this PR is fail-closed behavior, it'd be worth an explicit if master is None: return False guard (or handling it in main() before dereferencing).

2. Compiled .pyc files were committed

The diff adds three binaries: scripts/deploy/__pycache__/lease.cpython-313.pyc, run.cpython-313.pyc, test_run.cpython-313.pyc. .gitignore only excludes bench/__pycache__/, not scripts/deploy/__pycache__/, so these likely came from running test_run.py directly without PYTHONDONTWRITEBYTECODE=1 (the Makefile sets that env var for ci-signoff, but a direct python3 scripts/deploy/test_run.py run wouldn't). Recommend removing the three .pyc files from the PR and broadening .gitignore to a blanket __pycache__/ or *.pyc so this doesn't recur.

3. test_run.py isn't wired into any GitHub Actions workflow

test_ignore_list_matches_the_workflow is the one test that keeps run.py's UNDEPLOYABLE list in sync with deploy.yml's paths-ignore — exactly the kind of drift that caused the original production incident. It's only reachable via make ci-signoff (Makefile:187-194), which isn't invoked by .github/workflows/ci.yml or deploy.yml, and the prepush hook only runs fmt/lint. So a future edit to deploy.yml's paths-ignore without a matching UNDEPLOYABLE update (or vice versa) would merge cleanly with no CI signal, silently reintroducing the same class of bug this PR fixes. Worth adding a CI job step (or at least documenting that ci-signoff is required before merging changes here).

Nothing else stood out — the fail-closed except subprocess.CalledProcessError: return False design and the current != master and not only_undeployable(...) short-circuit both read correctly, and the test coverage for the intended behavior is solid.

A documentation merge landing seconds after a code merge cancelled that code
deploy. The rollout saw a newer master commit and declined, while the docs
push started no rollout of its own because deploy.yml ignores those paths.
The result was a green deploy job and production still running the previous
image, with nothing reporting the gap. It stranded #251 for half an hour on
2026-09-10 and was found only by checking the live digest by hand.

The supersede check now asks whether the newer commits could change the image
at all, and defers only when they could. It fails closed: if the newer commits
cannot be fetched or inspected, the answer is the previous behaviour.

The tests cover both directions, an empty diff, and drift between run.py's
list and deploy.yml's paths-ignore, which would otherwise be silent.
Three .pyc files rode along with the deploy-supersede fix. They are build
output of the very scripts beside them, they are platform- and
version-specific (cpython-313), and #261 already established that generated
artifacts do not belong in the working tree. .gitignore now says so, which is
what stops the next run of the tests re-adding them.
@tonyalaribe
tonyalaribe force-pushed the fix/deploy-ignores-docs-supersede branch from 4e978d6 to 721eccb Compare September 12, 2026 10:33
@tonyalaribe

Copy link
Copy Markdown
Contributor Author

Verified and rebased onto current master.

This is live right now, not hypothetical. #262 merged at 09:48Z, its Build and Deploy went green at 10:08:55Z having built image be9722a5, and the log ends:

A newer master commit superseded this rollout; production is unchanged.

The superseding commits were two docs-only pushes at 09:56Z and 10:01Z. Production is still on 3eed2cab with docker service inspect reporting UpdatedAt=09:39:25Z — so merged code has been stranded for over an hour, exactly the shape this PR describes for #251.

Can-fail proof re-run by me on the rebased branch: forcing only_undeployable to return False (the pre-change behaviour) turns all 4 tests red; restored, all 4 pass.

One change on top: three scripts/deploy/__pycache__/*.pyc files had ridden along. Removed and .gitignored — they are build output of the scripts beside them, and #261 already settled that generated artifacts stay out of the tree. (The Makefile already sets PYTHONDONTWRITEBYTECODE=1, so they came from a manual run.)

The 'merge when convenient rather than mid-backfill' caveat is satisfied: rollup_median_contiguous_days = 30 and cells_missing = 0, so that backfill has converged.

@tonyalaribe
tonyalaribe merged commit d894483 into master Sep 12, 2026
1 check passed
@tonyalaribe
tonyalaribe deleted the fix/deploy-ignores-docs-supersede branch September 12, 2026 10:33
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown

Review

Solid, well-scoped fix. The root cause (current != master treating any newer commit as a supersede, even a docs-only one that deploy.yml's paths-ignore would never itself trigger a rollout for) is correctly diagnosed, and the fix is narrowly targeted with no crate-source impact.

Things done well:

  • Fails closed correctly, including the subtle case. if not changed: return False before the all(...) check means an empty diff (identical trees) is not vacuously treated as "undeployable-only" — a naive all() over an empty list returns True, which would have been the wrong default here. Good catch.
  • Drift-proofing test (test_ignore_list_matches_the_workflow) is the standout piece — it ties UNDEPLOYABLE back to deploy.yml's paths-ignore via regex parsing so the two can't silently diverge again. Nice call using regex instead of pulling in a YAML dependency just for this.
  • Test cases cover both directions (docs-only vs. code), mixed diffs, and the identical-commit edge case — good coverage for a pure function like this.
  • git diff --name-only current..master is the right call here (two-dot, tree-to-tree comparison — doesn't need ancestry/merge-base, which is what's actually wanted).

One suggestion (non-blocking): the except subprocess.CalledProcessError: return False fallback is silent. Given the whole motivation for this PR is a previous silent failure that took manual digest-checking to catch, it might be worth a print(..., file=sys.stderr) in that branch so a future fetch failure (network blip, SHA-fetch disallowed on some remote config, etc.) is distinguishable in the logs from a genuine "newer commit changes the image" supersede — both currently print the identical "A newer master commit superseded this rollout" message from main(), so there'd be no way to tell after the fact which case occurred.

Minor/no action needed: the Makefile change looks like dead code at a glance (exit $$result appears to precede it), but the preceding three lines are joined by \ continuation into one recipe line, and each subsequent line runs in its own shell per Make's default — so the new test_run.py line is reachable and mirrors the existing test_lease.py line correctly.

No security or performance concerns — this only adds one extra shallow (--depth=50) fetch on the already-rare "current != master" path, and only touches CI/deploy tooling.

🤖 Generated with Claude Code

tonyalaribe added a commit that referenced this pull request Sep 12, 2026
#262 went live at 10:36Z once #258's merge pushed a non-ignored path — a docs
merge deploys nothing, so stranded code needs a code push to land.

At 31 minutes: 8 obsolete base files proven reproduced (no futile rebuild
minted), 19 not (minted as before), base_generation_unverified = 15 retries over
those 19 files. rollup_published_empty_over_full_base stayed 0 and
rollup_median_contiguous_days stayed 30 — the silent-short-publish failure mode
this change risked did not occur.
tonyalaribe added a commit that referenced this pull request Sep 12, 2026
* Review group commit in production and record what it exposed

Group commit works: journal_hold avg 5,452 -> 3,093 us and the lock's duty
cycle 18.5% -> 14.5% at a comparable process age (NOT against the 29.6% from
the 19h process — a mature process is not the comparator for a 44-minute one).
PR #263 is separately confirmed by journal_stats_publishes at 0.80/s against a
~1/s target.

The cost did not disappear, it became measurable. performed 30,075 +
coalesced 30,506 = 60,581 waiters exactly, 11.4 fsync cycles/s is one per
88 ms, and 11.4 x 88 ms is the whole second — the commit pipeline runs at
~100% duty, so every arrival queues. block.journal_commit_wait averages 362 ms
with a 10.7 s max, on the pre-ack path. The ~2x coalescing is OPTIMAL for
arrival-rate x fsync-duration, so batching harder buys nothing; the lever is
cost per commit.

Three findings recorded: the deploy pipeline stranding merged code (observed
live on #262, second instance after #251, fixed by #258); unbounded growth of
Complete task history driving compact() under the global mutex; and the no-op
rollup skip being defeated by restart frequency, which raises the value of the
content-fingerprint tag follow-up.

* Record #262's production verification and how #258 unstranded it

#262 went live at 10:36Z once #258's merge pushed a non-ignored path — a docs
merge deploys nothing, so stranded code needs a code push to land.

At 31 minutes: 8 obsolete base files proven reproduced (no futile rebuild
minted), 19 not (minted as before), base_generation_unverified = 15 retries over
those 19 files. rollup_published_empty_over_full_base stayed 0 and
rollup_median_contiguous_days stayed 30 — the silent-short-publish failure mode
this change risked did not occur.
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