Skip to content

feat: multi-actor concurrency harness for race-prone mutations (#218) - #379

Merged
github-actions[bot] merged 5 commits into
TRELLIS-STELLAR:mainfrom
OkeyAmy:feat/218-concurrency-harness
Oct 18, 2026
Merged

github-actions[bot] merged 5 commits into
TRELLIS-STELLAR:mainfrom
OkeyAmy:feat/218-concurrency-harness

Conversation

@OkeyAmy

@OkeyAmy OkeyAmy commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Closes #218

What this delivers

A multi-actor concurrency harness for race-prone mutations, plus harness-driven tests against the two critical writes with the most exposure: the idempotency claim that guards every idempotent write, and the approval-intent claim that guards maintainer actions. All four scenarios from the acceptance criteria — concurrent actors, duplicate submissions, stale versions, retry storms — are covered as invariant tests, with an operator guide covering edge cases, failure modes and recovery.

The harness — src/testing/concurrency/

Sits alongside the existing chaos/ and fault-injection/ helpers:

  • runConcurrently releases every actor through one start barrier: each operation's synchronous prefix runs before any actor can complete, so the race actually races. Staggered Promise.all starts never overlap, and a broken claim path silently passes.
  • duplicateSubmissions and retryStorm model the two production shapes: identical payloads arriving at once (double-clicks, proxy replays) and one client hammering while the original is still in flight.
  • Reports classify every outcome (status, exception class, message), so assertions read as invariants — "exactly one winner", "every loser saw 409", "the handler ran once" — rather than as microtask ordering. Rejections are captured as data; Promise.all would hide all but the first.
  • Deterministic by construction: no sleeps, no jitter, no probability. The barrier itself is under test — if it degrades, races everywhere silently stop racing.

What the harness caught (all fixed here)

  1. Unique-index losers surfaced as raw 500s. Both the entity comment and the service docs promise concurrent duplicates get a 409 — but IdempotencyService.execute never handled the INSERT collision: the loser's SQLSTATE 23505 went straight to the client. The claim is now a bounded loop that catches the violation, re-reads the winner's record, and either replays it, rejects with 409, or — the winner's attempt having failed — claims the key itself.
  2. Cancel and expiry could clobber live executions. ApprovalIntentService.cancel and expireIntent did read → check → unconditional save: a withdrawal racing an in-flight execution rewrote executing rows to cancelled, and two racing cancellations could both "succeed" and double-write evidence. Both now claim with UPDATE ... WHERE status = 'proposed' and answer 409 with the observed status when the claim is gone — the discipline execute already used. Cancel-vs-execute mutual exclusion is now structural.
  3. Two spec fakes were silently broken. The idempotency fake's remove() ignored arrays (TypeORM accepts both, and purgeExpired passes one — the purge test never deleted anything), and the interceptor harness lacked getHandler/getClass (all seven tests died on a TypeError). That suite went from 10/18 to 18/18.

Known limitation, documented rather than papered over

Intent proposal dedupe is sequential-strength only: genuinely concurrent identical proposals can both persist, because the table has no live-row uniqueness on fingerprint. Duplicates stay single-use, audited and cancel-able; docs/CONCURRENCY_HARNESS.md records the hardening path (partial unique index + conflict mapping) as follow-up rather than smuggling a migration into a test-focused PR.

Scenario coverage

Scenario Target Pinned invariant
Concurrent actors intent claim one proposed → executing winner, one operation run, losers 409
Duplicate submissions idempotency handler runs once; losers 409 — never a raw 500
Stale versions intent + idempotency state re-read from the store; stale writes lose or replay
Retry storms in-flight and post-terminal 409s throughout; work never runs twice
Withdrawal vs execution cancel racing execute exactly one side effect; the row agrees with which

Failure modes and recovery (in the guide)

Why 409 storms from retry loops are correct responses; the two stuck-row modes with SQL recovery recipes — idempotent in_progress rows (TTL expiry + purgeExpired, early-release DELETE) and intents stuck executing (... AND receipt IS NULL guard so a completed execution can never be faked into failed). Rollback: no schema changes anywhere in this PR — both fixes are code-only and backward compatible with every existing status code and response shape.

Verification

14 suites / 122 tests across the affected surface, all green (harness self-tests, idempotency 18+5 concurrency, intents 53+4 concurrency, plus migrations, quota and admin-role suites). The concurrency suites were run three times back to back with identical results. npx tsc --noEmit reports nothing for any file in this PR. The intent-side specs build on the approval-intent foundation that landed in #377/#378.

…IS-STELLAR#218)

runConcurrently / duplicateSubmissions / retryStorm give every race the
same shape: one start barrier (all actors enter before any can complete),
and a report that classifies every outcome so tests assert invariants
(exactly one winner, everyone else 409) instead of microtask ordering.
Rejections are captured as data - Promise.all would hide all but the
first. Deterministic by construction: no sleeps, no jitter, no threads.

Self-tests pin the barrier itself; if it ever degrades, races everywhere
silently stop racing.
…IS-STELLAR#218)

The entity documents 'the loser fails on the unique index and is mapped
to a 409', and the service doc promises concurrent duplicates are
rejected with 409 - but neither was true: execute() never caught the
INSERT collision, so a racing duplicate surfaced SQLSTATE 23505 to the
client, and two losers could both pass the lookup. The claim is now a
bounded loop: on a unique violation (isUniqueViolation: driver code or
message fallback) it re-reads the winner's record and replays it, rejects
with 409, or - their attempt already failed - claims the key itself.

Spec repairs uncovered while making the suite trustworthy: the fake
repository's remove() ignored arrays (TypeORM remove accepts both, and
purgeExpired passes one - the purge test never actually deleted), and the
interceptor harness lacked getHandler/getClass, so all seven interceptor
tests died on a TypeError. 18/18 now pass (was 10/18).

Adds the harness-driven concurrency suite: duplicate submissions execute
the handler exactly once, in-flight retry storms see 409s, stale views
replay instead of re-running, and payload-differing key reuse loses with
the documented message.
…S-STELLAR#218)

cancel() and expireIntent() did read - check the loaded copy - then
save unconditionally, so a withdrawal racing a live execution would
rewrite the executing/completed row to cancelled/expired, and two racing
cancellations could both 'succeed' and double-write evidence. Both now
claim with UPDATE ... WHERE status = 'proposed' and answer 409 with the
observed status when the claim is gone - the same discipline execute()
already used. execute()'s claim-failure path also re-reads state so the
409 says whether the winner was another execution or a terminal row.

Mutual exclusion between cancel and execute is now structural: exactly
one side effect can land, whichever conditional update wins first.
…TELLAR#218)

Extract the in-memory repository/signer/builders from the lifecycle spec
into testing/approval-intent.testkit.ts so the concurrency suite observes
the exact same claim semantics - one fake, two suites, no divergence.

New harness coverage around the intent claim: five concurrent actors
yield exactly one executing claim and one operation run; a retry storm
against a completed intent gets nothing but 409s; a stale proposed-state
view loses cleanly to the peer's completion; and cancel-vs-execute under
a race leaves exactly one side effect with the row agreeing on which.
…STELLAR#218)

Usage, why plain Promise.all lies, the scenario-to-invariant table, the
three races this work surfaced (unique-index loser as 500, cancel/expire
clobbering executions, and the documented sequential-only proposal
dedupe limitation), operator failure modes, SQL recovery for stuck
in_progress rows and stuck executing intents, and rollback paths.
@github-actions

Copy link
Copy Markdown

Code Review & Auto-Merge — @dorismaduegbunam 🚀

Thank you @OkeyAmy for your contribution!

Verified code changes and repository requirements. Merging pull request.

@github-actions
github-actions Bot merged commit 37e1010 into TRELLIS-STELLAR:main Oct 18, 2026
1 of 3 checks passed
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.

Trellis API: Build multi-actor concurrency harness for race-prone mutations

1 participant