Skip to content

Propagate deadlines in the background, without waiting for locks - #24

Merged
SupraSummus merged 2 commits into
mainfrom
claude/festive-euler-nagxwf
Oct 8, 2026
Merged

SupraSummus merged 2 commits into
mainfrom
claude/festive-euler-nagxwf

Conversation

@SupraSummus

Copy link
Copy Markdown
Member

Problem

Adding a precondition moved deadlines of it and all its preconditions, recursively, in the same transaction. That locked goals along existing dependencies from dependent to precondition, while a worker finishing a goal locks it and updates its dependents' counters, the other way. Reproduced deadlocks:

  • a worker returns RetryMeLater(precondition_goals=[B]), B depends on C, C is being achieved. Postgres aborts one of them: either the handler's work is rolled back, or C's finished handler is rolled back and runs again.
  • schedule(precondition_goals=[B]) from outside a worker (e.g. a web request), the same way.

Even without a deadlock, schedule() waited for any goal being pursued down the dependency graph, for as long as its handler ran.

Change

  • _add_precondition_goals only marks its new dependencies whose precondition has a later deadline (GoalDependency.deadline_propagated=False).
  • New handle_deadline_propagation(), run by busy and threaded workers, moves deadlines over marked dependencies, one level per call. It uses row-level locks with SKIP LOCKED only, so it never waits for a goal being pursued: a dependency on a locked goal stays marked for a later call.
  • A precondition whose deadline moved gets Goal.deadline_propagated=False, and the next call marks its late dependencies. Marking them in the same transaction could deadlock with another call holding them; the goal row is already locked, so the flag costs no waiting. An edge-only variant deadlocked 59 times in a 40 s stress run, this one 0.
  • Each piece of work is recorded once and found by a partial index. Checked with EXPLAIN on 200k goals: adding a precondition to a goal with 50k preconditions touches one row.
  • Migration 0012 adds both flags and their partial indexes; existing rows start as propagated.
  • update_goals_deadline is removed.
  • TODO: add the deadlock in the counter lock of _add_precondition_goals (still there, separate change) and the duplicated handler lists; drop the pypi.yml chore fixed on main.

Behavior change: deadlines reach preconditions in the background, one level per worker turn, not inside schedule().

Testing

  • pytest (94), mypy, flake8 on Django 6.1 and 5.2; makemigrations --check.
  • A new test holds a goal locked like a worker does, with lock_timeout set: on main it fails with LockNotAvailable, here it passes.
  • Each of 7 deliberate breakages of the propagation (waiting for locks, dropping skipped dependencies, busy loop on a locked goal, ...) fails the tests.
  • Stress run, not committed: 40 s on a random DAG with concurrent propagation, simulated workers, schedule() calls and deadline drops. main: 29 deadlocks; this branch: 0, and after settling no unachieved precondition has a later deadline than its dependent.

🤖 Generated with Claude Code

https://claude.ai/code/session_017FZQDBWAKp5JbsKYHbMfox


Generated by Claude Code

SupraSummus and others added 2 commits October 8, 2026 17:22
Adding a precondition moved the deadline of it and of all its
preconditions, recursively, in the same transaction. That updated goals
along existing dependencies, from dependent to precondition, while a
worker finishing a goal holds it and updates its dependents' counters,
the other way. Both directions on the same pair of goals deadlocked:
- a worker returning RetryMeLater([B]), where B depends on C, while C
  was being achieved; Postgres aborted one of them, so its work was
  rolled back (or C's handler ran again),
- schedule(precondition_goals=[B]) from outside a worker, the same way.
Even without a deadlock, schedule() waited for any goal being pursued
down the dependency graph, for as long as its handler ran.

Now _add_precondition_goals only marks its new dependencies on
preconditions with a later deadline (deadline_propagated=False).
handle_deadline_propagation, run by busy and threaded workers, moves
the deadline over marked dependencies, one level per call. It locks
goals with SKIP LOCKED, so it never waits for one being pursued:
a dependency on a locked goal stays marked for a later call.
A precondition whose deadline moved gets marked itself, and the next
call marks its late dependencies. Marking them at once could deadlock
with another call holding them.

Each piece of work is recorded once and found by a partial index, so no
list of preconditions is scanned more than once per deadline change.

- remove update_goals_deadline
- existing goals and dependencies start as propagated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FZQDBWAKp5JbsKYHbMfox
…ists

Drop the pypi.yml chore, fixed by "Run the publish workflow only on main
and tags".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FZQDBWAKp5JbsKYHbMfox
@SupraSummus
SupraSummus merged commit af227d3 into main Oct 8, 2026
5 checks passed
@SupraSummus
SupraSummus deleted the claude/festive-euler-nagxwf branch October 8, 2026 17:26
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