mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 14:22:03 +00:00
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:
@@ -0,0 +1,88 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMarshalStateChangedEnvelopeProducesDeliverableShape(t *testing.T) {
|
||||
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||||
payload, err := MarshalStateChangedEnvelope("match-1", 7, "LIVE", now, []string{"player-a", "player-b"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
// Decode with exactly the struct api.deliverStateOutboxEvent uses, then
|
||||
// apply exactly its acceptance predicate. This is the regression guard for
|
||||
// the initial-connect payload that omitted every one of these keys.
|
||||
var envelope struct {
|
||||
Event string `json:"event"`
|
||||
Revision uint64 `json:"revision"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
State string `json:"state"`
|
||||
MatchID string `json:"match_id"`
|
||||
PlayerIDs []string `json:"player_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &envelope); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if envelope.Event != "state_changed" || envelope.ResourceID != "match-1" || envelope.Revision != 7 ||
|
||||
envelope.State == "" || len(envelope.PlayerIDs) == 0 {
|
||||
t.Fatalf("envelope would be rejected by the dispatcher: %+v", envelope)
|
||||
}
|
||||
if envelope.MatchID != "match-1" || !envelope.OccurredAt.Equal(now) {
|
||||
t.Fatalf("unexpected envelope: %+v", envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalOutboxEnvelopeRejectsUndeliverablePayloads(t *testing.T) {
|
||||
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||||
valid := OutboxEnvelope{
|
||||
Event: "state_changed", ResourceID: "match-1", Revision: 1,
|
||||
OccurredAt: now, State: "LIVE", MatchID: "match-1", PlayerIDs: []string{"player-a"},
|
||||
}
|
||||
if _, err := MarshalOutboxEnvelope(valid); err != nil {
|
||||
t.Fatalf("baseline envelope must be valid: %v", err)
|
||||
}
|
||||
|
||||
for name, mutate := range map[string]func(*OutboxEnvelope){
|
||||
"no event": func(e *OutboxEnvelope) { e.Event = "" },
|
||||
"no resource": func(e *OutboxEnvelope) { e.ResourceID = "" },
|
||||
"no state": func(e *OutboxEnvelope) { e.State = "" },
|
||||
"no timestamp": func(e *OutboxEnvelope) { e.OccurredAt = time.Time{} },
|
||||
"no recipients": func(e *OutboxEnvelope) { e.PlayerIDs = nil },
|
||||
"empty recipient": func(e *OutboxEnvelope) { e.PlayerIDs = []string{"player-a", ""} },
|
||||
"duplicate recipient": func(e *OutboxEnvelope) { e.PlayerIDs = []string{"player-a", "player-a"} },
|
||||
// -1 is the "nothing matched" sentinel several CTEs return. Untyped as
|
||||
// uint64 it would become 18446744073709551615 in the payload.
|
||||
"sentinel revision": func(e *OutboxEnvelope) { e.Revision = -1 },
|
||||
"reserved extra": func(e *OutboxEnvelope) { e.Extra = map[string]any{"state": "CANCELLED"} },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
envelope := valid
|
||||
mutate(&envelope)
|
||||
if _, err := MarshalOutboxEnvelope(envelope); err == nil {
|
||||
t.Fatalf("expected %s to be rejected at construction", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalOutboxEnvelopeOmitsMatchIDForProposals(t *testing.T) {
|
||||
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||||
payload, err := MarshalOutboxEnvelope(OutboxEnvelope{
|
||||
Event: "proposal_changed", ResourceID: "proposal-1", Revision: 0,
|
||||
OccurredAt: now, State: "OPEN", PlayerIDs: []string{"player-a"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, present := decoded["match_id"]; present {
|
||||
t.Fatalf("proposal envelope must not carry a match_id: %s", payload)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user