mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
1dd05c75f1
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.
89 lines
3.4 KiB
Go
89 lines
3.4 KiB
Go
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)
|
|
}
|
|
}
|