fix(server): repair the initial-connect outbox envelope and unblock dispatch

ApplyInitialConnectPlan wrote a payload of {match_id,state,action},
omitting event, revision, resource_id, occurred_at and player_ids --
every field deliverStateOutboxEvent requires. Delivery rejected the row,
dispatch returned on the first error so it was never acknowledged, and
because reads are ordered oldest-first it was retried ahead of every
later state_changed event on every 100ms poll. One initial-connect
transition therefore blocked lifecycle delivery for all matches, not
just its own.

Two independent fixes, since either alone leaves the system fragile:

Build envelopes through one validating helper (MarshalOutboxEnvelope)
and convert all five writers to it. A writer that omits a required
field now fails its own transaction instead of committing a row that
can only ever poison the queue. The helper takes revision as int64 so
the -1 "nothing matched" sentinel some CTEs return surfaces as an error
rather than wrapping to a huge uint64.

Make dispatch resilient regardless: a delivery failure is now counted
against that row and the batch continues, with the row dead-lettered
after MaxOutboxDeliveryAttempts so a poison event degrades to one lost
notification instead of a stalled queue. Ordering within an aggregate
is still honoured -- later events of a failed match are deferred, so no
client observes that match's newer state before its older state. An ack
failure still stops the batch, being a database rather than a payload
problem.

Initial-connect events now address every participant, not just the
connected ones: a no-show needs to learn their ticket was failed and a
penalty applied.
This commit is contained in:
Josh Creek
2026-09-05 10:14:43 +01:00
parent 2c648514ba
commit 1dd05c75f1
12 changed files with 379 additions and 31 deletions
+2 -9
View File
@@ -5,7 +5,6 @@ import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"time"
@@ -156,10 +155,7 @@ func AdvanceServerRegistration(ctx context.Context, db *sql.DB, binding domain.W
if err := rows.Close(); err != nil {
return err
}
payload, err := json.Marshal(map[string]any{
"event": "state_changed", "revision": revision, "resource_id": binding.MatchID,
"occurred_at": now, "state": string(to), "match_id": binding.MatchID, "player_ids": playerIDs,
})
payload, err := MarshalStateChangedEnvelope(binding.MatchID, revision, string(to), now, playerIDs)
if err != nil {
return err
}
@@ -279,10 +275,7 @@ func BindAllocatedMatch(ctx context.Context, db *sql.DB, allocation domain.Alloc
if err := rows.Close(); err != nil {
return err
}
payload, err := json.Marshal(map[string]any{
"event": "state_changed", "revision": revision, "resource_id": allocation.MatchID,
"occurred_at": allocation.AllocatedAt, "state": string(domain.Allocating), "match_id": allocation.MatchID, "player_ids": playerIDs,
})
payload, err := MarshalStateChangedEnvelope(allocation.MatchID, revision, string(domain.Allocating), allocation.AllocatedAt, playerIDs)
if err != nil {
return err
}