fix(decisioning): supervise timed and canceled work - #1000
Conversation
| task.cancel("client disconnected") | ||
|
|
||
| with pytest.raises(asyncio.CancelledError, match="client disconnected") as exc_info: | ||
| await task |
| assert await asyncio.to_thread(entered.wait, 1) | ||
| task.cancel("client disconnected") | ||
| with pytest.raises(asyncio.CancelledError, match="client disconnected"): | ||
| await task |
| assert await asyncio.to_thread(entered.wait, 1) | ||
| task.cancel() | ||
| with pytest.raises(asyncio.CancelledError): | ||
| await task |
|
|
||
| task.cancel("buyer disconnected") | ||
| with pytest.raises(asyncio.CancelledError, match="buyer disconnected"): | ||
| await task |
| assert await asyncio.to_thread(entered.wait, 1) | ||
| task.cancel("buyer disconnected") | ||
| with pytest.raises(asyncio.CancelledError, match="buyer disconnected"): | ||
| await task |
| """Settle a sync worker after its request task has been cancelled.""" | ||
| try: | ||
| result = await worker_future | ||
| except BaseException as exc: |
| webhook_target=webhook_target, | ||
| webhook_auto_emit=webhook_auto_emit, | ||
| ) | ||
| except BaseException: |
| try: | ||
| await on_failure(exc) | ||
| except Exception: | ||
| except BaseException: |
| ) | ||
| ) | ||
| except Exception: | ||
| except BaseException: |
There was a problem hiding this comment.
Approving. Right shape: a cancelled or timed-out request can no longer release a durable reservation while the worker thread is still mutating state, and the fix bounds the pre-existing thread-pool slot leak instead of papering over it.
The load-bearing move is asyncio.shield over the wrapped worker future plus a supervised settle-task (_settle_cancelled_sync_lifecycle) that fires on_complete/on_failure from the worker's real terminal outcome — so the CONSUMING→CONSUMED transition happens even after the buyer disconnects. Verified in test_sync_create_media_buy_cancellation_waits_for_worker_success.
Things I checked
- Permit accounting is leak-free.
SyncExecutorAdmissionacquires beforeexecutor.submit, releases exactly once via the future's done-callback (fires on success and worker exception), and releases in theexcepton submit failure without adding the callback. Release is marshaled back withloop.call_soon_threadsafe— correct, sinceasyncio.Semaphoreis not thread-safe (time_budget.py:95,dispatch.py:1445-1470). - No double settlement. Direct-sync cancel sets
sync_lifecycle_continues=Truein the innerexcept asyncio.CancelledError; the re-raisedCancelledErrorthen re-enters the outerexcept BaseException, which sees the flag and skips_safe_on_failure_call. Router-path cancel carries the_adcp_sync_worker_futuremarker and is settled once in the outer handler.security-reviewerandcode-reviewerboth traced this — single settlement each path. _project_invocation_resultextraction is faithful.params→request_paramsrename threads through; the sync/handoff/workflow arms are unchanged; happy path is unchanged.get_adcp_capabilitiesINTERNAL_ERROR alignment closes a wire leak. It was the last outlier hand-rollingcaused_by={type, message}; it now routes through_internal_error_message/_internal_error_details, which emit{type}only.str(exc)is deliberately dropped so a platform that raises on secret material can't leak an OAuth secret on the wire. Confirmed by the new assertions attest_decisioning_capabilities_projection.py:640-642.- Not a wire-contract break.
ad-tech-protocol-expert: sound — perschemas/cache/3.1/core/error.json,detailsisadditionalProperties: trueandcaused_byis not a defined property; the normative recovery signal iserror.recovery(stillterminal).incomplete[]-when-saturated is invisible to the buyer and matches the existing timeout contract. - No tenant/auth regression.
_run_sync_delegatecarries only admission + executor through two ContextVars; the tenant-scopedctxis still a positional arg, and_bind_routed_sync_executionresets both tokens infinallyso nothing bleeds across sibling delegates. proposal_dispatch.pyexcept Exception→except BaseException. Awaitingrelease_consumptioninside aCancelledErrorhandler is safe — the exception is already caught, the bareraisere-raises it, and the innerexcept BaseExceptionabsorbs a second cancel during release. This is the point: cancellation must not strand a CONSUMING reservation.
Follow-ups (non-blocking — file as issues)
- Routed, no-deadline
get_productscancel drops itson_complete. In_run_sync_delegatethesync_admission is Nonebranch (platform_router.py:135-139) sets the marker and re-raises, but dispatch's outer handler only builds a settle-task whenon_failure is not None.get_productswireson_complete=_persist_draft_hookwith noon_failure, so on client-disconnect the worker is orphaned (stray "Task exception was never retrieved" on failure; draft-persist silently skipped on success). No reservation is held on this path, so it's not corruption — but it's the one gap in "supervise cancelled work." Supervise wheneveron_complete is not None or on_failure is not None. asyncio.BoundedSemaphoreoverSemaphore. Accounting is correct today, but a future strayrelease()would silently raise the ceiling abovetimed_sync_get_products_limitrather than fail-fast (time_budget.py:95).- Admission is per-handler, not per-tenant. One tenant bursting slow/cancelled timed
get_productscan hold allworkers//2permits and stall other tenants' timed calls. Strictly better than the pre-PR shared-pool exhaustion; key onctx.accountif per-tenant fairness matters.
Minor nits (non-blocking)
- Stale comment now contradicts the invariant.
dispatch.py:1529-1532still reads "We expose only the exception class name + str (not the traceback)."_internal_error_detailsno longer exposesstr— that omission is the whole point of the sanitization this PR leans on. Pre-existing, but adjacent enough that a maintainer could "restore" thestrto match the comment and reintroduce the leak. Drop "+ str."
Independent concurrency review claimed in the PR body; the test matrix (direct-sync, eager/lazy router, campaign-unit bypass, saturation-without-queue-growth) covers the new paths well. Safe to merge once CI is green.
Summary
Why
Timed-out or cancelled requests could release reservations while worker threads were still mutating state. Under load this allowed duplicate work and unbounded executor queues.
Validation
origin/mainCompatibility
Admission and executor context now propagate through eager, lazy, proposal-manager, and registry routers. Existing direct asynchronous platform behavior is unchanged.