fix(matcher): stop stranding orders when a process is killed - #25
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Gate 2 of the Railway → AWS cutover: make the services survive being stopped.
The defect
A matcher killed between
reserveOrdersand the deferred release intickInstrumentleaves both orders in'matching'permanently. Nothing recovers them:status='active', so the order vanishes from itexpireOrdersonly touches'active', so it never ages out'active', so the owner cannot withdraw it(owner_address, nonce)index keeps that nonce consumedThe 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/markets—ctx := 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.NotifyContexton SIGTERM/SIGINT, soRunreturns and the in-flight tick unwinds normally. The deferred release already built its context withcontext.Background()viadetachedContext, 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), anddesired_count_matcheris 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.ListenAndServewith 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 30sderegistration_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:The API likewise exits 0 through
api shutting down, draining in-flight requests→api 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 withfilled 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_sincecolumn. 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