Skip to content

fix(approvals): drain shielded outcome writes before shutdown, per gate, logged once (BACKLOG #2087) - #1814

Merged
wshallwshall merged 5 commits into
mainfrom
b180/2087-approvals-shield
Sep 29, 2026
Merged

wshallwshall merged 5 commits into
mainfrom
b180/2087-approvals-shield

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Batch 180 (webconsole), one item. Its own PR because it changes a dual-control security control.

BACKLOG #2087 -- approvals shielded-outcome follow-ups (limbs 1, 2, 3, 5 and PR 1636 item 5)

  • Source branch: b180/2087-approvals-shield
  • Head SHA: 7d43dcde6577700555a66484f40cbc5648c964a8
  • Base when built: 7412632003af0d09a966f2460cdfd11e6819ab1a

What changed (messagefoundry/api/approvals.py, the create_managed_app teardown in app.py, tests/test_approvals.py):

  • Limb 1, drain. ApprovalGate.drain(timeout=3.0) waits for outcome writes still running, re-reading the set so a write started mid-drain is drained too. It cancels nothing, and logs any still running at the deadline at ERROR. The managed lifespan calls it before the summary-audit flush and before engine.stop(), guarded so neither a failure nor the deadline skips engine.stop(). The gate is read through getattr because an early startup failure may not have built one. 3 s, not 10: it shares NSSM's 15 s graceful-stop window with the upload runner's 5 s stop.
  • Limb 2, per gate. The module-global _SHIELDED set is gone; _shielded is a gate method tracking self._inflight.
  • Limb 3, log once. Both asyncio.shield uses are replaced with _outlive_caller (asyncio.wait then task.result()). A shield reports a late failure itself ("exception in shielded future"), so one failure was logged twice. The Builder measured this red on origin/main: both log-once tests saw exactly 2 reports. Note the readings missed the direct asyncio.shield(claim) in approve(); the row's "a failed settle logs twice" was accurate for that path.
  • PR 1636 item 5. A lost-race 409 on an orphaned write logs one WARNING with no traceback; any other status stays ERROR.
  • Limb 5, tests. Ten new tests, including a lifespan drain that lands a write durably (with a neutralised-drain control) and a raising drain that still reaches engine.stop(), order checked.

Not built, fenced. Limb 4 (a row left executing after a failed status write) waits on #1562's startup-reconciler / process-ownership design. No route out of executing was written, and test_a_failed_approved_write_still_writes_the_audit_row is unchanged.

Review notes left open (code-review xhigh, 10 findings, 5 fixed): a lifespan cancelled mid-drain skips engine.stop() (the existing flush guard has the same shape; fixing both is a separate change); the embedded create_app(engine=...) path never drains (the docstring tells embedders to call drain()).

Checks the Builder ran. ruff check, ruff format --check, mypy messagefoundry (302 files), mypy --explicit-package-bases tests (1020 files) clean. pytest over approvals, summary flush, approver provenance, requester recheck, min dwell, apiclient approval hold, lifespan startup unwinds, audit integrity, logging, api_tls and neighbours: 487 + 151 + 59 passed before repairs, 122 + 10 passed after. Full suite not run locally.

Legs not seen locally. The Postgres and SQL Server store-contract legs (no store method changed), and windows-service-smoke, which runs this teardown under NSSM.

Proposed ledger banner: PARTIAL -- limbs 1, 2, 3 and 5 and PR 1636 item 5 shipped (per-gate drain before engine.stop(), log once). Limb 4 remains open on #1562's startup-reconciler design.

wshallwshall added 2 commits September 29, 2026 15:53
…te, logged once (BACKLOG #2087)

The approval gate's shielded outcome writes (BACKLOG #1562) had three gaps.

- Nothing drained them before engine.stop() closed the store. ApprovalGate.drain()
  now waits, bounded by DRAIN_TIMEOUT_SECONDS, and the create_managed_app lifespan
  calls it before engine.stop(), guarded so a failure or the deadline never skips
  the stop. Writes still running at the deadline are logged with their approval ids.
- The task set was module-global. It is now held per ApprovalGate instance.
- A write that raised after its caller was cancelled was logged twice: once by the
  gate and once by asyncio.shield itself ("exception in shielded future", through
  the loop exception handler). The claim path had the same shape. Both now wait
  with asyncio.wait, which never cancels the task and reports nothing, so the gate's
  line is the only one.

Folded in PR 1636 item 5: a resolve whose caller was cancelled and that then lost
the race logs its 409 at WARNING without a traceback, not at ERROR.

Limb 4 (a row left 'executing' after a failed status write) is NOT built. It waits
on #1562's startup-reconciler and process-ownership design;
test_a_failed_approved_write_still_writes_the_audit_row still pins today's behaviour.

Tests: single-report caplog tests across the gate and asyncio loggers for a failed
cancelled claim and for a cancelled resolve that loses the race; a cancelled claim
that loses the race; drain wait, deadline and per-gate scope; the lifespan drain
with a neutralised-drain control; a failing drain still reaches engine.stop().

BACKLOG #2087
Proposed PR title: fix(approvals): drain shielded outcome writes before shutdown, per gate, logged once (BACKLOG #2087)
Proposed ledger banner: PARTIAL -- limbs 1, 2, 3 and 5 shipped (bounded per-gate drain before engine.stop(), one log per failed shielded write, PR 1636 item 5's lost-race 409 at WARNING, the two missing tests). Limb 4, a way out of a stuck 'executing' row, remains open on #1562's startup-reconciler design.
…ogger (BACKLOG #2087)

From the code-review subagent at xhigh:

- DRAIN_TIMEOUT_SECONDS drops from 10 to 3. It shares NSSM's 15 s graceful-stop
  window with the upload runner's 5 s stop, and engine.stop() still has to run.
- drain() re-reads the task set until it is empty or the deadline passes, so a
  write started while it waits is drained too. New test pins it.
- Only a 409 ApprovalError from an orphaned write logs at WARNING; any other
  ApprovalError keeps ERROR and its traceback.
- Tests: collect garbage before counting reports, so an error nobody read cannot
  hide as one report; the no-drain control holds its write until the lifespan
  has exited instead of racing a fixed sleep against teardown.

BACKLOG #2087
Proposed PR title: fix(approvals): drain shielded outcome writes before shutdown, per gate, logged once (BACKLOG #2087)
Proposed ledger banner: PARTIAL -- limbs 1, 2, 3 and 5 shipped (bounded per-gate drain before engine.stop(), one log per failed shielded write, PR 1636 item 5's lost-race 409 at WARNING, the two missing tests). Limb 4, a way out of a stuck 'executing' row, remains open on #1562's startup-reconciler design.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

QA: code-review xhigh subagent, 10 findings, 5 fixed, 5 left (3: mid-drain cancel skips engine.stop, same shape as the existing flush guard, separate change; 5: unread claim error only on loop-exit cancel, settle is drained; 8: introspection link cosmetic; 9: embedded create_app path documented to call drain(); 10: dead guard removed in the #2 rewrite)

@wshallwshall wshallwshall added the qa Builder QA record posted; not a merge gate label Sep 29, 2026
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 29, 2026
wshallwshall added 3 commits September 29, 2026 17:40
…g the installed handler (BACKLOG #2087)

The two log-once tests asserted the running loop had no exception handler, so a
shield's report would reach the 'asyncio' logger. The tests share one session
loop, and any create_managed_app lifespan earlier on it installs the engine's
last-resort handler (messagefoundry/last_resort.py, install_loop_exception_handler,
called from the lifespan in messagefoundry/api/app.py) and never removes it. On
the windows-2025 leg that ordering held, and the assertion failed.

Each test now installs its own capturing handler for its duration, restores the
previous one, and asserts that nothing reached it. Measured both ways: the new
tests pass with the last-resort handler leaked in first and on a clean loop, and
against the pre-fix approvals.py they fail in both states.

BACKLOG #2087
Proposed PR title: fix(approvals): drain shielded outcome writes before shutdown, per gate, logged once (BACKLOG #2087)
Proposed ledger banner: PARTIAL -- limbs 1, 2, 3 and 5 shipped (bounded per-gate drain before engine.stop(), one log per failed shielded write, PR 1636 item 5's lost-race 409 at WARNING, the two missing tests). Limb 4, a way out of a stuck 'executing' row, remains open on #1562's startup-reconciler design.
…s (BACKLOG #2087)

Review repair. The capturing handler counted every loop exception, so garbage
left by earlier tests on the shared session loop, collected inside the window,
could fail a log-once test for a reason unrelated to it. _loop_reports() now
collects before it installs its handler, and a failure names the exception.

BACKLOG #2087
Proposed PR title: fix(approvals): drain shielded outcome writes before shutdown, per gate, logged once (BACKLOG #2087)
Proposed ledger banner: PARTIAL -- limbs 1, 2, 3 and 5 shipped (bounded per-gate drain before engine.stop(), one log per failed shielded write, PR 1636 item 5's lost-race 409 at WARNING, the two missing tests). Limb 4, a way out of a stuck 'executing' row, remains open on #1562's startup-reconciler design.
@wshallwshall
wshallwshall added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit ec971fd Sep 29, 2026
42 of 44 checks passed
@wshallwshall
wshallwshall deleted the b180/2087-approvals-shield branch September 29, 2026 23:38
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander review of the repair commits (7d43dcde6..7b22c59e1). A code-review subagent ran at xhigh; no tag. Verdict: ready.

  • The merge e349117dfb adds nothing beyond main: its remerge-diff is empty.
  • Measured: the two log-once tests fail against pre-fix approvals.py. The old test file fails only when the handler has leaked. A shield-regression mutant is caught by the capture assertion.

Low notes, none blocking:

  • The settles_nothing test's new assertion cannot fail pre-fix, so the commit message overstates it.
  • The 'asyncio' logger in _REPORTING_LOGGERS is a possible flake source; this predates the PR.
  • The handler leak also comes from tests/test_pool_warm.py:211, not only the lifespan. It will be filed as its own row.

Re-arming.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying. qa Builder QA record posted; not a merge gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant