UN-3719 [FIX] reaper poison orphan-claims — 404 (not 500) for a deleted execution → GC the claim#2177
Conversation
… → GC the claim pg_orchestration_claim has no FK to workflow_execution, so retention/cleanup that deletes old executions leaves orphan claim rows. The orphan-claim sweep looks the execution up to decide GC-vs-recover, but the backend returned 500 (bare except swallowed Http404), which the reaper treats as transient → retains the claim forever. Result: 3 poison claims failing every sweep in integration. - backend retrieve(): catch Http404 / DoesNotExist → 404 before the generic 500. - APIRequestError carries status_code; get_workflow_execution propagates it into ExecutionResponse.status_code. - reaper _execution_status: confirmed 404 → _EXECUTION_GONE sentinel; 5xx/transport still raises (retain). _recover_one_claim GC's a gone execution's orphan claim (re-guarded on still-orphan-and-old). Tests: backend 404-on-missing; worker gone/transient/success detection + GC wiring. Worker 24 + backend 17 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
| Filename | Overview |
|---|---|
| backend/workflow_manager/internal_views.py | Returns 404 only after an unscoped query confirms that the execution is absent. |
| workers/queue_backend/pg_queue/reaper.py | Handles confirmed deletion as a cleanup signal while retaining claims after unconfirmed failures. |
| workers/shared/clients/base_client.py | Adds HTTP status information to API request errors. |
| workers/shared/clients/execution_client.py | Propagates request status codes into failed execution responses. |
| workers/shared/data/response_models.py | Allows execution error responses to carry an optional HTTP status code. |
| backend/workflow_manager/execution/tests/test_pg_finalization_fixes.py | Covers missing and organization-scoped execution lookups. |
| workers/tests/test_reaper_orphan_claim_404.py | Covers deletion detection, transient failures, status propagation, cleanup, and re-claim races. |
Reviews (2): Last reviewed commit: "UN-3719 [FIX] address review — gate GONE..." | Re-trigger Greptile
muhammad-ali-e
left a comment
There was a problem hiding this comment.
Automated PR review (PR Review Toolkit: code-reviewer, silent-failure-hunter, type-design-analyzer, pr-test-analyzer, comment-analyzer, code-simplifier). The status_code propagation chain (base_client → execution_client → ExecutionResponse → reaper) is correct and well-documented. Findings below are ranked; the org-scoped-404 issue already raised by @greptile-apps is not re-posted (comment 1 extends it with the infra/deploy-skew vector + a concrete fix). Highest-priority item is a second, un-updated caller of _execution_status.
| # (a deterministic 404 — deleted by retention/cleanup). Distinct from None (a | ||
| # readable execution with no status) and from a status string, so _recover_one_claim | ||
| # can GC the pure-orphan claim instead of retaining it forever on an unreadable read. | ||
| _EXECUTION_GONE: Final = object() |
There was a problem hiding this comment.
[P1] Second caller of _execution_status doesn't handle _EXECUTION_GONE → misleading ValueError, barrier path never GC'd.
_execution_status now has two callers, but only _recover_one_claim (line 725) learned about the new sentinel. _recover_one_barrier (line 487) still guards only status is None and then calls ExecutionStatus.is_completed(status) (line 497). For a deleted execution that still has a stranded barrier row, status is _EXECUTION_GONE (a bare object()), so is_completed() does ExecutionStatus(<object>) → ValueError: Invalid status: <object …>. The per-row except Exception (line 588) catches it, rolls back, increments failed, and retains the row — so the barrier for a gone execution churns every sweep and can trip the "recovered nothing → systemic failure" escalation.
This isn't a fresh regression (pre-PR that path raised RuntimeError and also retained), but the fix is applied to the claim path and left uncovered on the sibling barrier path, and the failure now surfaces as a misleading exception rather than the deliberate one.
Root cause is the type: _execution_status(...) -> str | object | None (line 344) — object supersets str and None, so the union collapses to object and no checker flags the second call site. Fix both together:
- Make the sentinel a closed type so call sites are exhaustively checkable, e.g. a single-member
Enum(str | _StatusRead | Nonedoes not collapse). Noteenumis not currently imported in this module. - Handle
status is _EXECUTION_GONEin_recover_one_barrierbefore theis_completedcall — delete the stranded barrier /pg_batch_deduprows for the gone execution (mirror the claim-path GC), or at minimum skip cleanly with a clear log.
There was a problem hiding this comment.
Good catch — fixed in 0a740136b. _recover_one_barrier now has a status is _EXECUTION_GONE branch before is_completed(), so the sentinel no longer hits ExecutionStatus(<object>) → ValueError. GONE → log + fall through to the existing cleanup (GC the orphaned pg_barrier_state + pg_batch_dedup, no status overwrite), symmetric with the claim path.
| execution_id, organization_id=organization_id, file_execution=False | ||
| ) | ||
| if not getattr(response, "success", False): | ||
| if getattr(response, "status_code", None) == 404: |
There was a problem hiding this comment.
[P1] Extends @greptile-apps (org-scoped 404): a bare HTTP 404 is overloaded beyond "row deleted".
Greptile already flagged that an org-scope mismatch 404s a live execution here. The same status_code == 404-only check also fires for 404s that have nothing to do with the execution being gone: a rolling-deploy URL/API-version skew, a reverse proxy / load balancer, or an auth gateway. Any of these → _EXECUTION_GONE → _delete_orphan_claim. Blast radius is large: during a bad deploy every status read 404s, so every orphan claim is GC'd in a single sweep.
The fix data is already on the response: the backend returns a distinguishing body {"error": "WorkflowExecution not found"} (internal_views.py:163), and execution_client puts the 404 body into response.error (execution_client.py:140), which survives the to_dict() round-trip onto the object read here. Require both signals before acting:
if not getattr(response, "success", False):
if (getattr(response, "status_code", None) == 404
and "WorkflowExecution not found" in (getattr(response, "error", "") or "")):
return _EXECUTION_GONE
raise RuntimeError(...) # any other 404 (route/proxy/auth) is transient → retainA bare 404 with no app-level marker is exactly the "unconfirmed status" the docstring says must be retained. (Minor: HTTPStatus.NOT_FOUND reads better than the literal 404 and compares equal.)
There was a problem hiding this comment.
The concern is valid and the fix data is indeed already on the response. Tracing the chain confirms it:
- Backend (
internal_views.py) returns{"error": "WorkflowExecution not found"}with 404 base_client._make_requestraisesAPIRequestError(f"Client error: 404 Not Found: {response_text}", status_code=404)—response_textis the JSON body stringexecution_client.pycatches the exception and setserror=str(e)→response.error="Client error: 404 Not Found: {\"error\": \"WorkflowExecution not found\"}"
So "WorkflowExecution not found" in response.error will be True for the app-level 404 and False for a proxy/gateway/routing 404. The dual-check is the right fix:
| if getattr(response, "status_code", None) == 404: | |
| if not getattr(response, "success", False): | |
| if ( | |
| getattr(response, "status_code", None) == 404 | |
| and "WorkflowExecution not found" | |
| in (getattr(response, "error", "") or "") | |
| ): | |
| return _EXECUTION_GONE |
A proxy or load-balancer 404 would carry a generic HTML/plain-text body that won't contain the app-level marker, so it correctly falls through to the raise RuntimeError(...) path and retains the claim. The string is stable since it's set in a single place in internal_views.py — worth adding a brief comment there or in a constant if you want to make the coupling explicit, but the check itself is solid.
There was a problem hiding this comment.
Adopted your dual-signal in 0a740136b: status_code==404 AND "WorkflowExecution not found" in (response.error or ""). A bare 404 (proxy/gateway/version skew) now raises → retained, so a bad deploy can't GC every claim in one sweep. Added tests: 404-with-marker→GONE, 404-without-marker→raise.
| serializer = WorkflowExecutionContextSerializer(context_data) | ||
| return Response(serializer.data) | ||
|
|
||
| except (Http404, WorkflowExecution.DoesNotExist): |
There was a problem hiding this comment.
[P1/P2] retrieve emits an ambiguous 404 — root-cause backend fix for the org-scoped-404 issue.
Two problems make this 404 mean more than "row deleted", and the reaper turns any such 404 into an irreversible claim GC:
- Org scoping.
get_object()runs against the org-filtered queryset, andresolve_organizationreturnsqueryset.none()when the claim'sorganization_iddoesn't resolve (or is stored in the wrong PK-vs-string format) →get_object_or_404raisesHttp404for an execution that still exists. Before returning 404, confirm the row is genuinely gone irrespective of org scope:
if not WorkflowExecution.objects.filter(id=kwargs.get('id')).exists():
return Response({"error": "WorkflowExecution not found"}, status=404)
# else the row exists but is scoped out / another failure → 500 (retain), not 404- Over-broad
try. Thisexceptwraps the entire method body, so a downstreamHttp404(e.g. a nestedget_object_or_404in_get_source_config/_get_destination_config) is masked as "WorkflowExecution not found" → false 404 → GC of a live execution's claim. Narrow thetryto just theget_object()call and run the context-building outside it, so downstream failures surface as 500 (retain).
Also: WorkflowExecution.DoesNotExist in the tuple is unreachable — DRF's get_object() raises Http404, never the bare model exception; drop it. And add organization_id to the log line so a wrongful GC is traceable.
There was a problem hiding this comment.
Fixed in 0a740136b — took the unscoped-existence approach: on Http404/DoesNotExist, retrieve checks WorkflowExecution.objects.filter(id=…).exists() (the manager only scopes on user context, so this is unscoped in the service-to-service internal path). Genuinely absent → 404; exists-but-scoped-out or a nested downstream Http404 → 500 (retain). This closes both the org-scope and the over-broad-try holes at once. Backend test added for the scoped-out→500 case.
| """Raised when an API request fails. | ||
|
|
||
| pass | ||
| ``status_code`` carries the HTTP status when the failure was an HTTP error |
There was a problem hiding this comment.
[P2] APIRequestError docstring overstates the status_code contract (rot risk).
The docstring presents a clean dichotomy — HTTP error response → carries a code; transport failure → None. But several branches raise APIRequestError without a status_code even though they are HTTP-level or request-level failures: rate-limit exhaustion (429, ~line 384), the catch-all "Other errors" branch (~line 396), and data-serialization failure (~line 325). So an HTTP error can also yield status_code=None.
This doesn't affect the reaper (404 is always populated on the client-error branch), but a future caller who trusts "any HTTP error carries a code" would be misled. Suggest narrowing the wording to: "carries the HTTP status for a non-timeout 4xx or a retry-exhausted 5xx (e.g. 404 / 500); None for transport failures and for a few paths that don't thread it (429-exhausted, serialization, generic fallback). Only rely on it for those 4xx/5xx cases."
(Related maintainability note, no change required: this reuses BaseResponse.status_code, which elsewhere defaults to 200/400 and means "this response's HTTP status" — here on the error path it means "the upstream call's status, or None." Same field, two null-semantics; worth a one-line note on the field, or a distinct name like upstream_status_code.)
There was a problem hiding this comment.
Narrowed the docstring in 0a740136b to your wording: status_code is populated only for a non-timeout 4xx / retry-exhausted 5xx; None for transport failures and the 429-exhausted / serialization / generic-fallback paths — 'don't assume any HTTP error carries a code.'
| error=str(e), | ||
| execution_id=str(execution_id), | ||
| message="Failed to retrieve workflow execution", | ||
| status_code=getattr(e, "status_code", None), |
There was a problem hiding this comment.
[P2] The status_code propagation chain this feature depends on has no test coverage.
Every 404 test (test_reaper_orphan_claim_404.py) hand-sets .status_code on a MagicMock; none constructs a real ExecutionResponse, raises a real APIRequestError, or calls this method. So the three cooperating production pieces are untested end-to-end:
base_client.py:345,366—APIRequestError(..., status_code=response.status_code)- this line —
error_response(..., status_code=getattr(e, "status_code", None)) response_models.py:220— the newstatus_codeparam
A rename of APIRequestError.status_code, or a change to the getattr attribute name here, leaves every unit test green while _execution_status sees status_code=None on a real 404 → raises → the reaper is back to retrying deleted-execution claims forever (the exact bug this PR fixes). Add unit tests: (a) APIRequestError("m", status_code=404).status_code == 404 and default None; (b) patch the HTTP layer to raise APIRequestError(status_code=404/500) and assert the returned ExecutionResponse.status_code matches, and a plain Exception → None; (c) a base_client test that a 404 response raises APIRequestError whose .status_code == 404.
There was a problem hiding this comment.
Added TestStatusCodePropagationChain in 0a740136b: constructs a real ExecutionAPIClient, patches the HTTP layer to raise a real APIRequestError(status_code=404/500), calls get_workflow_execution, and asserts the returned ExecutionResponse.status_code + .error marker. Plus TestApiRequestErrorStatusCode (carries 404 / defaults None). A rename in the chain now fails a test.
|
|
||
| assert self._call(_resp(False, status_code=404)) is _EXECUTION_GONE | ||
|
|
||
| def test_500_raises_transient(self): |
There was a problem hiding this comment.
[P2] Add a non-404 4xx case to pin the "only 404 is GONE" boundary.
The suite covers 404 (GONE), 500 (raise), and transport/None (raise), but never a non-404 4xx such as 403/401/409. _execution_status special-cases only == 404; if someone later loosens that to >= 400 or in client_error_range, live claims sitting behind a transient 403 (auth blip) or 409 would be silently GC'd and no test would fail. Add e.g. test_403_raises_and_is_not_gone: _resp(False, status_code=403) must pytest.raises(RuntimeError) and must not return _EXECUTION_GONE.
There was a problem hiding this comment.
Added test_403_raises_and_is_not_gone (and test_404_without_marker_raises) in 0a740136b — pins 'only the app-level 404 is GONE'; loosening to >=400 would now fail here instead of silently GC-ing live claims behind a 403/409.
|
Confirmed on all findings. Full assessment below. [P1]
|
…er path, unscoped 404 Review round on #2177: - [P1] reaper _execution_status: require BOTH status_code==404 AND the backend marker 'WorkflowExecution not found' before returning _EXECUTION_GONE. A bare 404 from a proxy/gateway/deploy-skew (or an org-scoped 404 for a live execution) no longer triggers a mass claim GC. - [P1] _recover_one_barrier: the second _execution_status caller now handles the _EXECUTION_GONE sentinel (was ExecutionStatus.is_completed(<object>) → ValueError, churning every sweep). GONE → clean up the orphaned barrier/dedup rows, no mark. - [P1/P2] backend retrieve(): confirm the row is genuinely absent (unscoped .exists()) before 404; else 500 so the reaper retains the claim. Fixes org-scoped-404 + nested-Http404. - [P2] APIRequestError docstring narrowed; tests for the real status_code propagation chain, 404-without-marker + 403 raise, backend scoped-out → 500. - SonarCloud S5778: one throwing call per pytest.raises. Worker 30 + backend 18 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Review addressed (
|
6b4425d
into
feat/UN-3445-pg-queue-integration



What
The reaper's orphan-claim sweep can now GC claims whose execution was deleted, instead of failing on them every cycle.
Why
pg_orchestration_claimhas no FK toworkflow_execution, so retention/cleanup that deletes old executions leaves orphaned claim rows. The sweep reads a claim'sexecution_idand looks it up (inworkflow_execution) to decide GC-vs-recover — but the backendretrieveswallowedHttp404in a bareexcept Exceptionand returned 500. The reaper's_execution_statustreats any failure as transient (raises → retain the claim), so a permanently-deleted execution poisoned every sweep:Observed live in integration: 3 claim rows (dated Apr–Oct) whose execution + file-execution rows are gone (0 rows), failing every 5-min sweep.
How
retrieve(): catchHttp404/WorkflowExecution.DoesNotExist→ 404 (before the generic 500).APIRequestErrorcarriesstatus_code;get_workflow_executionpropagates it intoExecutionResponse.status_code._EXECUTION_GONEsentinel → GC the orphan claim (re-guarded on still-orphan-and-old). A 5xx / transport failure still raises → claim retained (unchanged — never terminalize on an unconfirmed read).Safety
_orphan_claim_where(old + still-orphan) before deleting.Tests
retrievereturns 404 for a missing execution._execution_status→ GONE on 404, raises on 5xx/transport, returns status on success;_recover_one_claimGC's a gone execution and leaves it on a re-claim race.UN-3719 — follow-up to #2172 / #2174 / #2175.
🤖 Generated with Claude Code