Skip to content

UN-4513 [FIX] Stop execution status updates from blocking file-execution inserts - #2376

Open
muhammad-ali-e wants to merge 6 commits into
mainfrom
claude/project-thread-h4e325
Open

muhammad-ali-e wants to merge 6 commits into
mainfrom
claude/project-thread-h4e325

Conversation

@muhammad-ali-e

@muhammad-ali-e muhammad-ali-e commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Requested by ali · project thread

What

  • update_status (internal API) now takes one row lock on workflow_execution instead of two, and takes it as FOR NO KEY UPDATE instead of FOR UPDATE. The reaper's _recover_one_stuck_pg_execution does the same.
  • update_execution() gains a keyword-only row_locked=True flag for callers that already locked this instance: it routes and guards on the instance's own locked fields instead of locking the row again.
  • The counter floor's two file-row COUNTs become one aggregate query.

Why

  • PG-queue load findings Basic workflow and adapter exception messages #4: the view locked the row with select_for_update(), then update_execution() locked it again. FOR UPDATE conflicts with the FOR KEY SHARE that every child-row INSERT (file executions, logs) takes for its foreign-key check, so those inserts queued behind each status write.
  • On staging: ~28 FOR KEY SHARE checks per file, 5,385 s of total lock wait, and execution selects waiting up to 8 minutes.

How

  • select_for_update(no_key=True). FOR NO KEY UPDATE still conflicts with itself and with FOR UPDATE, so these still serialize exactly as before:
    • status writers;
    • _record_dispatch_handle's UPDATE;
    • deletes;
    • any other select_for_update() on the row.
  • It only stops conflicting with FOR KEY SHARE, which guards the primary key, and nothing here changes the primary key.
  • PG path: the view locks and passes row_locked=True. Celery path: the view takes no lock and update_execution() takes its own. One lock either way.
  • row_locked=True raises outside a transaction, since a lock that has already been released proves nothing.
  • The file count stays after the lock, so it sees whatever a status writer it waited behind committed (e.g. the reaper cascading files to ERROR). An earlier revision counted before the lock; Greptile showed that can save failed_files=0, so it was reverted.
  • Django creates PostgreSQL foreign keys as DEFERRABLE INITIALLY DEFERRED, so an uncommitted file insert holds no lock on the execution row until it commits. Neither lock mode ever waited for an in-flight insert, so the change does not lose any "wait for inserters" barrier. A test pins this.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No behaviour change intended. The guards are unchanged:
    • COMPLETED/STOPPED still refuse reverts on PG rows.
    • ERROR stays correctable.
    • Celery rows keep the legacy full save.
    • The rate-limit release still fires on commit.
    • A DB error in the floor count still fails the request (500, worker retries), exactly as on main.
  • What does change: a file-execution insert's commit no longer waits for an in-flight status write on the same execution. Before, it waited and then committed right after, so the end state is the same.

Database Migrations

  • None.

Env Config

  • None.

Relevant Docs

Related Issues or PRs

Dependencies Versions

  • None.

Notes on Testing

  • New backend/workflow_manager/execution/tests/test_update_status_locking.py (17 tests), run against a real Postgres 16:
    • Query capture:
      • exactly one FOR NO KEY UPDATE and no FOR UPDATE on the PG path, the Celery path, a refused write, the reaper, and plain update_execution();
      • the file count runs once, after the lock;
      • no count when no floored counter is written;
      • a real SQL error in the count is attempted once, returns 500 and commits nothing;
      • row_locked skips the second lock and is rejected outside a transaction.
    • Concurrency (TransactionTestCase, real threads and connections):
      • a child insert with lock_timeout = 2s commits while a status write holds the lock. On main it fails with canceling statement due to lock timeout ... FOR KEY SHARE, the staging symptom;
      • an uncommitted insert holds no lock on the execution row, even against FOR UPDATE;
      • a callback waiting behind a reaper-style ERROR cascade floors failed_files at the committed error rows;
      • a second writer still waits for the first and is then refused over COMPLETED;
      • 8 racing writers never revert COMPLETED;
      • 6 racing attempt increments land as 6.
  • Existing test_pg_finalization_fixes.py (41) pass, and 432 tests pass across workflow_manager, pg_queue and api_v2. The 3 failures in test_destination_connector_postgres.py fail identically on main (the local DB has no schema for the connector).
  • Three rounds of automated review run before requesting human review. Findings on pre-existing main behaviour were left out of scope: view routing on the unlocked get_object() snapshot, and increment_attempt computed before the lock.

🤖 Generated with Claude Code

https://claude.ai/code/session_015YNhfqAovVwDvPZDYrPyE3

update_status locked the workflow_execution row twice with FOR UPDATE (once
in the view, again inside update_execution) and counted the file rows while
holding it. FOR UPDATE conflicts with the FOR KEY SHARE every child-row
INSERT takes for its foreign-key check, so file-execution inserts for the
same execution queued behind each status write (staging waits up to 8 min).

- Lock once: the view hands its locked read to update_execution(locked_row=),
  and the reaper's recovery does the same.
- Lock with FOR NO KEY UPDATE: status writers still exclude each other, child
  inserts no longer wait.
- Count the file rows for the counter floor in one query before the lock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YNhfqAovVwDvPZDYrPyE3
@muhammad-ali-e muhammad-ali-e self-assigned this Oct 26, 2026
@greptile-apps

greptile-apps Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no new actionable issues were found.

Summary

This PR lets file inserts commit without waiting for execution status writes.

  • Workflow status writes no longer block child-row inserts.
  • File counters come from one combined count of execution files.

Both earlier review concerns are addressed: the count runs after any lock wait, and failed counts are attempted once. No new actionable issues were found.

Reviews (4) · Last reviewed commit: "UN-4513 [FIX] Address review: observe th..." · Reviewed by Greptile

Comment thread backend/workflow_manager/internal_views.py Outdated
Comment thread backend/workflow_manager/internal_views.py Outdated
Counting before the lock let a status write that waited behind another
writer (e.g. the reaper cascading files to ERROR) floor its counters at a
stale count and save failed_files=0. Count after the lock again (still one
aggregate query), and run it in a savepoint so a failed count no longer
aborts the status transaction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YNhfqAovVwDvPZDYrPyE3
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@muhammad-ali-e muhammad-ali-e changed the title [FIX] Stop execution status updates from blocking file-execution inserts UN-4513 [FIX] Stop execution status updates from blocking file-execution inserts Oct 26, 2026
… count plumbing

- WorkflowExecution.STATUS_LOCK_FIELDS + status_lock_snapshot() replace the
  hand-built locked_row dicts, so callers and update_execution's own locked
  read cannot drift.
- The reaper no longer passes file_row_counts; the floor recounts as on main.
- Test documents that an uncommitted file insert holds no lock on the
  execution row (deferred FK), under either lock mode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YNhfqAovVwDvPZDYrPyE3
… main

- Drop the savepoint around the file-row count: a failed count aborts the
  transaction and the request is retried, instead of committing counters the
  floor never checked (failed_files too low reads as success).
- Replace locked_row dicts with a row_locked flag that reads the locked
  instance's own fields, so a mismatched snapshot cannot be passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YNhfqAovVwDvPZDYrPyE3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YNhfqAovVwDvPZDYrPyE3

@athul-rs athul-rs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE WITH NITS: Critical 0 · High 0 · Medium 1 · Low 4 · Lenses run: 19/19 (examined 15, N/A 4)

The lock change holds up. FOR NO KEY UPDATE still conflicts with itself, with FOR UPDATE, with plain UPDATEs (including update_execution_queue_message_id) and with DELETE, so every status writer still serializes. Both row_locked=True call sites pass an instance re-read under the lock and not mutated before the call. Every other update_execution() caller keeps the default and re-reads under lock, and the Celery path takes one lock as before.

Security deep-dive: done, no findings at confidence >= 8. Pinned ruff v0.3.4 check and format --check pass on the 3 files. The test suite was not run here (needs Postgres).

Lens checklist
  1. Spec & intent: Clean
  2. Architectural fit & precedent: See findings #2
  3. Correctness & edge cases: See findings #3
  4. Security: Clean
  5. Data integrity & migrations: Clean
  6. Concurrency: Clean
  7. API & contract compatibility: Clean
  8. Reliability & resilience: Clean
  9. Performance & cost: Clean
  10. Observability: Clean
  11. Operational safety: Clean
  12. LLM/agent-specific: N/A (no model or agent code)
  13. Testing: See findings #1, #4
  14. Dependencies & build: N/A (no dependency change)
  15. Code quality: Clean
  16. Doc & comment accuracy: See findings #2, #3, #5
  17. Cross-document rule consistency: Clean
  18. On-prem deployment experience: N/A (no chart, values or on-prem docs)
  19. Scaling & workload lifecycle: N/A (no worker or pool config)

Assumptions: READ COMMITTED isolation; every FK into workflow_execution was created by Django migrations, so it is DEFERRABLE INITIALLY DEFERRED.

Reviewed with the unstract:review-deep rubric (plugin v2.1.73).

)
try:
st.join(1.0)
assert st.is_alive(), f"callback did not wait for the row lock: {sbox}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#1 [Medium] [Lens 13 — Testing] — "Waited for the lock" is proven by a 1 s wall-clock join

st.join(1.0); assert st.is_alive() is also true when the second thread has not reached its select_for_update yet (thread start, DB connect on a loaded CI runner). If it gets there only after release.set(), the holder has already committed: here a count-before-lock regression would read the committed ERROR rows and still pass, and at L451 a lost lock would pass because the straggler sees the committed COMPLETED either way. The failure direction is a false pass, so CI would never show it.

Suggested fix: before releasing, poll pg_stat_activity from the test connection (with a deadline) until a backend shows wait_event_type = 'Lock'. test_file_row_count_runs_once_and_after_the_lock already pins the ordering deterministically, so this tightens the threaded proof rather than filling a gap.

Unverified: does the waiter reliably reach the lock within 1 s on CI? Running the count-before-lock mutation under CPU stress would confirm.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, fixed in 4ad9da4. Both sites now poll pg_stat_activity with a deadline until another backend shows wait_event_type = 'Lock', and only then release the holder. They also fail fast if the second thread finishes without ever waiting.

I also tried removing every select_for_update(no_key=True), and both tests still pass. Even then the second writer queues on the holder's own UPDATE row lock, and the DB-level guard still refuses the revert. So these two tests show that a waiter waits and then sees the committed state; they don't pin the explicit lock. As you said, test_file_row_count_runs_once_and_after_the_lock and the single-lock query tests pin that.


Generated by Claude Code

)
try:
st.join(1.0)
assert st.is_alive(), f"second writer did not wait for the row lock: {sbox}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#1 (second site): same 1 s is_alive() proof as L388; a lost lock passes here if the second writer reaches the row only after release.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4ad9da4 with the same pg_stat_activity wait as the first site.


Generated by Claude Code

# inserts and log writes for this execution stop queueing behind a
# status update. Nothing here changes the primary key, which is the only
# thing FOR KEY SHARE protects.
if row_locked:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#2 [Low] [Lens 2 / Lens 16] — row_locked=True vouches for the instance; the comment above says the opposite

The block at L432-L439 still says "Lock the row and read the PERSISTED marker" and "Never trust the (possibly stale) in-memory self.queue_message_id", directly above a branch that does neither. The guard at L423 only checks connection.in_atomic_block, so a future caller passing row_locked=True on an unlocked snapshot inside atomic() passes silently and re-opens the stale-routing race that comment describes. Both current callers are correct; this is about the next copy of the pattern.

Suggested fix: smallest is one clause in the existing comment ("unless the caller passes row_locked=True; see the docstring"). Sturdier: take the locked values instead of a boolean (locked: dict | None = None), so the caller hands over what it read. The docstring's "read with select_for_update()" could also say "either mode", since every caller now uses no_key=True.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I took the small fix in 4ad9da4. The comment now carves out row_locked=True and points to the docstring, and the docstring says "(either mode)".

I'd rather not switch to locked: dict | None. The caller would still build that dict from the instance it locked, so a caller holding an unlocked snapshot inside atomic() could pass a dict just as wrongly as a boolean. It would widen the signature without closing that gap. Neither form can check that the lock is actually held, short of re-querying it, and re-querying is the round-trip this flag removes. So the guard stays at "a transaction is open", with the docstring stating the contract.


Generated by Claude Code

execution.save(update_fields=update_fields)

@staticmethod
def _count_file_rows(execution) -> "dict | None":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#3 [Low] [Lens 3 / Lens 16] — The broad except adds nothing on DB errors and skips the floor on any other error

A DB error is caught without a savepoint, so the transaction is already aborted and the next save() raises: the request does 500, but because of the aborted transaction, not this handler. Any non-DB exception returns None, skips the floor and commits the worker's counters with a 200, which is the under-counted failed_files the docstring says this prevents. Same shape as before this PR, so not a regression.

Suggested fix: drop the try/except and let errors reach the view's handler (same 500 for DB errors). test_count_failure_writes_counters_as_given would need updating. Also drop the two "as on main" clauses: after merge, "main" is this code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, fixed in 4ad9da4. I dropped the try/except, so any count error now fails the request and rolls back instead of committing unfloored counters with a 200. test_count_failure_writes_counters_as_given became test_count_error_fails_the_request_and_commits_nothing, which expects a 500 with nothing written. I also removed the "as on main" wording.

The reaper call site already wraps each execution in its own try/except that logs and returns "failed". An error there rolls back that one recovery, and the next sweep retries it.


Generated by Claude Code

]
for t, _ in runs:
t.join(WAIT_SECONDS)
for _, b in runs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#4 [Low] [Lens 13 — Testing] — Racing-writers test does not assert its threads finished

t.join(WAIT_SECONDS) is never followed by assert not t.is_alive(). A hung thread has neither error nor result in its box, so assert "error" not in b passes and only the final status is checked. (The attempts test below fails on a hang via b["result"].) Suggest asserting not t.is_alive() after each join and treating both racing tests as soak checks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4ad9da4. Both racing tests now assert not t.is_alive() after each join.


Generated by Claude Code

@@ -0,0 +1,505 @@
"""Row locking on the workflow-execution status write.

``update_status`` used to take ``SELECT ... FOR UPDATE`` on the execution row twice

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#5 [Low] [Lens 16] — Test-file prose overstates two things

Here: "used to take SELECT ... FOR UPDATE on the execution row twice" was true only for PG-transport rows; the view locked only when queue_message_id is not None. Suggest "For PG-transport executions, update_status used to take it twice".

L30: "only a broken lock makes the threads wait at all" is not true of this file; two tests assert a thread waits behind a correct lock. Suggest keeping only "a hang must fail the test rather than the CI job".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed both in 4ad9da4, using your wording.


Generated by Claude Code

… fail the request

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YNhfqAovVwDvPZDYrPyE3
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
⚪ e2e-agent-kv e2e 0 0 0 1 0.1
✅ e2e-api-deployment e2e 3 0 0 0 18.6
✅ e2e-coowners e2e 1 0 0 0 1.5
✅ e2e-etl e2e 1 0 0 0 12.8
✅ e2e-login e2e 2 0 0 0 1.3
✅ e2e-prompt-studio e2e 1 0 0 0 8.5
✅ e2e-smoke e2e 2 0 0 0 2.5
✅ e2e-workflow e2e 1 0 0 0 18.3
✅ frontend unit 629 0 0 0 17.6
✅ integration-backend integration 1437 0 0 26 82.7
✅ integration-connectors integration 1 0 0 7 9.7
✅ integration-workers integration 181 0 0 1 56.4
❌ ui e2e 0 1 0 0 0.0
✅ unit-agent-kv-schema unit 143 0 0 0 1.6
✅ unit-backend unit 2033 0 0 1 59.7
✅ unit-connectors unit 82 0 0 0 11.1
✅ unit-core unit 281 0 0 0 3.1
✅ unit-filesystem unit 1 0 0 0 4.3
✅ unit-platform-service unit 18 0 0 0 2.7
✅ unit-rig unit 120 0 0 0 4.4
✅ unit-runner unit 12 0 0 0 3.0
✅ unit-sdk1 unit 878 0 0 0 42.2
✅ unit-tool-sidecar unit 8 0 0 0 1.7
✅ unit-workers unit 1534 0 0 2 128.3
TOTAL 7369 1 0 38 491.9

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • platform-key-whoami — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

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.

4 participants