Skip to content

fix(matcher): stop stranding orders when a process is killed - #25

Merged
robertleifke merged 1 commit into
mainfrom
fix/matcher-shutdown-and-startup-sweep
Sep 7, 2026
Merged

fix(matcher): stop stranding orders when a process is killed#25
robertleifke merged 1 commit into
mainfrom
fix/matcher-shutdown-and-startup-sweep

Conversation

@robertleifke

Copy link
Copy Markdown
Contributor

Gate 2 of the Railway → AWS cutover: make the services survive being stopped.

The defect

A matcher killed between reserveOrders and the deferred release in tickInstrument leaves both orders in 'matching' permanently. Nothing recovers them:

  • the book reads status='active', so the order vanishes from it
  • expireOrders only touches 'active', so it never ages out
  • cancel also requires 'active', so the owner cannot withdraw it
  • the unique (owner_address, nonce) index keeps that nonce consumed

The order is simultaneously invisible to the book and still shown as OPEN to its owner, with no path out.

And there was no signal handling anywhere in services/marketsctx := context.Background() — so every ECS or Railway stop was that kill. Cutover is precisely when processes get stopped mid-tick, which is why this gates the migration rather than being general hygiene.

Changes

Graceful stop (the matcher). signal.NotifyContext on SIGTERM/SIGINT, so Run returns and the in-flight tick unwinds normally. The deferred release already built its context with context.Background() via detachedContext, specifically so it outlives cancellation — the mechanism was already correct, there was simply never anything to trigger it. A cancelled context is now reported as a clean stop rather than a failure.

Boot sweep (ReleaseStaleMatches). For the cases that get no chance to unwind — SIGKILL, OOM, a dead node — the matcher releases anything left in 'matching' at startup and logs the count.

Safe to run unconditionally, and only there: 'matching' is written in exactly one place (reserveOrders, on the matcher's own crossing path), and desired_count_matcher is validated <= 1, so no second matcher can hold a reservation this one would be stealing. Any row still in 'matching' when a matcher boots was stranded by a dead process.

Drain (the API). It served through bare http.ListenAndServe with no server handle, so a deploy dropped in-flight requests mid-response. It now shuts down with a 20s grace period — under the ALB's 30s deregistration_delay, so the drain completes while the load balancer is still holding new requests back, and under ECS's stop timeout so a wedged handler can't turn a graceful stop back into a SIGKILL.

Verification

Against a real PostgreSQL 18.6 in Docker, migrated with cmd/migrate:

WARN released orders stranded in matching by a previous process count=1
after boot: stranded-1=active
--- SIGTERM ---
exit code: 0
INFO matcher stopped

The API likewise exits 0 through api shutting down, draining in-flight requestsapi stopped, and the distroless self-probe still returns 0 against a live server.

The new integration test was mutation-checked: widening the sweep to also release 'filled' fails it with filled order status = "active", want filled (must not be swept). That matters — returning a settled order to the book would re-offer size that has already moved on chain. The test also asserts the sweep is idempotent on a clean boot.

Integration tests skip without MARKETS_SERVICE_TEST_DATABASE_URL, so CI is unaffected.

Not in scope

No lease or matching_since column. The single-matcher invariant makes one unnecessary today, and adding a column would be a migration for a case that cannot currently arise.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JN98Sjs2zHG8Z6jXkpHX7Q

A matcher killed between reserveOrders and the deferred release in
tickInstrument leaves both orders in 'matching' forever. Nothing recovers
them: the book reads status='active', expireOrders only touches 'active',
and cancel requires 'active' too -- so the order is invisible to the book,
still shows as OPEN to its owner, cannot be cancelled, and keeps that
owner's nonce consumed through the unique (owner_address, nonce) index.

There was no signal handling anywhere in services/markets
(ctx := context.Background()), so every ECS or Railway stop was that kill.
Cutover is exactly when processes get stopped mid-tick.

- The matcher handles SIGTERM/SIGINT via signal.NotifyContext, so Run
  returns and the in-flight tick unwinds. Its deferred release already used
  a detached context, so it survives the cancellation -- the mechanism was
  there, nothing ever triggered it.
- ReleaseStaleMatches sweeps anything left in 'matching' at boot, for the
  cases that get no chance to unwind: SIGKILL, OOM, a dead node. Safe to run
  unconditionally because 'matching' is written in exactly one place, on the
  matcher's own path, and desired_count_matcher is validated <= 1.
- The API drains in-flight requests instead of dropping them. It served
  through bare http.ListenAndServe with no handle; the grace period is 20s,
  under the ALB's 30s deregistration delay and under ECS's stop timeout.

Verified against a real Postgres 18.6: booting on a stranded row logs
"released orders stranded in matching by a previous process count=1" and
returns it to 'active'; SIGTERM exits 0 through "matcher stopped" and
"api stopped". The new integration test was mutation-checked -- widening the
sweep to 'filled' fails it, since returning a settled order to the book
would re-offer size that already moved on chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JN98Sjs2zHG8Z6jXkpHX7Q
@robertleifke
robertleifke merged commit eefd7f2 into main Sep 7, 2026
1 check passed
@robertleifke
robertleifke deleted the fix/matcher-shutdown-and-startup-sweep branch September 7, 2026 21:34
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.

1 participant