Brief host locks incorrectly rejected room creation
Production evidence
On September 11, 2026 at 09:39 UTC, a bounded test of four guest room creations returned two HTTP 503 responses despite both regional hosts having ample spare capacity. Railway request IDs were uoD97XifTmqNejucxtoGcA and SCJTkQ7nQhSaZ_5_wUFZXw; durations were 597 and 607 ms. Another concurrent creation at 09:43 UTC returned the same result.
The allocator selects a host with FOR UPDATE ... SKIP LOCKED. A concurrent allocation or heartbeat can briefly lock each eligible host row. Skipping all of them returned an empty result, which the allocator incorrectly treated as unavailability. This is a room-admission bug, not an explanation for an already running match losing its WebSocket connection.
Change
Keep the fast skip-locked selection. If that finds no candidate, retry the same selection while waiting for a host row under the existing two-second lock timeout. Recount capacity after acquiring the lock with a fresh database snapshot, as before. Admission limits, room caps, signed tickets, room ownership and active-match transport are unchanged. No schema migration is needed.
Add structured game.control.error logs for unavailable responses. Include the safe Railway request ID and a bounded reason (host availability/capacity, admission pressure, code exhaustion), or a PostgreSQL SQLSTATE for unexpected database failures. Do not log SQL, credentials, ticket bodies or user content. Response status, public error codes and retry headers remain unchanged.
Validation
The real-PostgreSQL concurrency check uses a temporary schema in the development database. A separate connection holds the only host row until a creation tries to select it, then releases it after 200 ms. Before this fix, creation rejected with unavailable. After the fix it completes. The existing duplicate-creation, join-race, capacity, quick-play and cancellation-race checks also pass.
All 12 room-directory, lifecycle and admission unit tests pass. Full monorepo build and typecheck are required before publication. Production verification should submit a small concurrent batch of guest creates and cancel every issued room afterward; no real player's room is used.
Limits and rollback
Long lock waits, exhausted capacity or admission pressure can still return a bounded 503. This is intentional; the change does not promise unlimited capacity or fix the separate intermittent transport failure. Only the main web service needs deployment. Game relay processes must not be restarted for this change. Rollback is a normal revert of the allocator fallback and associated diagnostics; there is no data migration to undo.
Preserve region preference under contention
The first fix deployed as d12fc8fe1, Railway deployment 9fdd1782-b652-4813-b044-096317f3c574. At 09:53 UTC, four concurrent guest creates all returned HTTP 200 and all four were explicitly cancelled. One US-preferred request still selected Asia while the US row was briefly locked.
This exposed a second effect of SKIP LOCKED: ordering by preferred region does not preserve that preference when its host rows are locked. The follow-up first selects within the requested region, including the bounded wait. Only when no eligible preferred-region host exists can a private room use another region. Strict-region matchmaking continues to reject cross-region fallback.
An additional real-PostgreSQL regression check holds the preferred US host while a free Asia host exists. Before the follow-up, it incorrectly assigns Asia; after the follow-up it must assign US. Draining US then verifies that actual unavailability still permits private-room fallback. This affects new room placement only, never migration of a running room.
