Skip to content

DM-56095: Stop edition_reconcile publishing editions deleted mid-tick - #887

Open
jonathansick wants to merge 4 commits into
mainfrom
tickets/DM-56095-reconcile-tombstone-race
Open

jonathansick wants to merge 4 commits into
mainfrom
tickets/DM-56095-reconcile-tombstone-race

Conversation

@jonathansick

@jonathansick jonathansick commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes the race in #886: a branch deleted while an edition_reconcile tick is running could get a publish_edition job enqueued for the edition the delete webhook had just soft-deleted, which then raised NotFoundError into Sentry (DOCVERSE-2Y) and left its queue row in_progress for four hours.

  • Reconcile re-check refuses soft-deleted editions. _recheck_republish now reads the edition's date_deleted, and its project's, under the same row lock as current_build_id (EditionStore.lock_republish_target, replacing lock_current_build_id). A soft-deleted edition raises _StalePlanError with a new _ApplySkip.deleted, so Phase A writes nothing and enqueues nothing. It is logged at info as "Skipped a republish for a soft-deleted edition".
  • New deleted_skipped counter. Those drops are counted on the tick outcome, the edition_reconcile job's progress, and the edition_reconcile_completed metrics event (a new required integer field), rather than in the standing tombstoned count.
  • publish_edition retires a missing target as a skip. When the project or edition the payload names is gone at pickup, or the slug resolves to an edition with a different id, the job completes with deleted_skipped in its progress instead of raising. The queue row closes at once and a keeper-sync run waiting on it still finalises. This covers a delete that lands between enqueue and pickup from any producer.
  • publish_edition locks the edition row at pickup. The edition is read FOR UPDATE, so a soft-delete still uncommitted at pickup is waited for and seen, rather than the job publishing a pointer for the tombstone.

Notes for review:

  • The edition and project skips in publish_edition share the deleted_skipped progress flag with the deleted-build skip; the message says which one it was.
  • A missing build or history row still raises NotFoundError, since no delete removes those.
  • A delete that commits after pickup has marked the pair publishing, but before the KV write, can still leave a key for a tombstone. The next reconcile tick removes it.
  • Code review also raised moving the tombstone check into enqueue_publish_for_edition for every producer, and treating an edition id mismatch as an error rather than a skip. Both are left as they are here.

Validation steps

  • uv run --only-group=nox nox -s test passes (4,292 tests), as do nox -s typing and prek run --all-files.
  • New tests:
    • tests/services/edition_reconcile_test.py::test_a_delete_between_plan_and_apply_drops_the_republish soft-deletes an edition between plan and apply and asserts no queue_jobs row is created, deleted_skipped is 1 and nothing reaches Sentry.
    • tests/worker/publish_edition_test.py::test_publish_edition_deleted_target_skips delivers a job for a soft-deleted edition, a soft-deleted project and a mismatched edition id, and asserts the row is completed with deleted_skipped and no Sentry event.
    • tests/storage/edition_store_test.py covers lock_republish_target (live, deleted edition, deleted project, missing row) and shows a FOR UPDATE get_by_slug waiting for an open delete and then reading nothing.
  • Not yet exercised on roundtable-dev. After deploy, DOCVERSE-2Y should stop receiving events, a mid-tick branch delete should show as the info log line above, and deleted_skipped should appear on edition_reconcile_completed.

References

🤖 Generated with Claude Code

A branch deleted while an edition_reconcile tick runs is soft-deleted
by the ref_deleted webhook after the plan has read the edition as live.
The delete also removes the CDN key, so the read-back reports a missing
pointer and the tick enqueued a publish for a tombstone.

The apply-time re-check now reads the edition's date_deleted, and its
project's, under the same row lock as current_build_id, and drops the
republish with nothing written. The edition is counted in the tick's
tombstoned bucket.
A delete landing between the enqueue and the pickup left publish_edition
unable to resolve the edition its payload named. It raised NotFoundError
into Sentry and left the queue row in_progress until
publish_edition_reaper failed it four hours later.

The job now completes with deleted_skipped in its progress, as it does
for a deleted build, so the row closes at once and a keeper-sync run
waiting on it still finalises. A slug that resolves to a different
edition id takes the same path.
Give republishes dropped for a soft-deleted edition their own counter
on the tick outcome, the queue job progress and the
edition_reconcile_completed event, instead of folding them into the
standing tombstoned count where the race is invisible.

Also correct the docs and docstrings that still said publish_edition
raises for a tombstoned project; it now retires as a skip.
A plain read returned the live row while a soft-delete's transaction
was still open, so the job could go on to write a pointer for the
tombstone that delete was about to commit. Reading FOR UPDATE waits for
the delete and then sees it.

Pass the project id resolved before the lock into the loader rather
than resolving the slug a second time, which also stops a project
delete in that gap being recorded as an edition delete. Add store tests
for lock_republish_target and the locked read.

This branch has not been deployed

No deployments
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.

edition_reconcile enqueues a publish for an edition soft-deleted mid-tick (NotFoundError, DOCVERSE-2Y)

1 participant