mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 18:33:43 +00:00
feat(server): add retention for idempotency, outbox and session records
Each ten-second queue heartbeat mints a fresh idempotency key and permanently inserts a row. Published outbox rows and expired/revoked sessions were never purged either -- the maintenance role performed lifecycle reconciliation only. At 10,000 queued players heartbeats alone add roughly 60,000 durable rows per minute, so table and index growth, vacuum pressure, backup size and recovery time were all unbounded on a service intended to scale horizontally. Add retention windows chosen to exceed every retry and recovery horizon that could still consult the row -- deleting an idempotency key early would turn a client replay into a second real mutation, so this is a correctness bound, not just a housekeeping one. Dead-lettered outbox rows are kept longest, being the record of events never delivered. Deletes run in bounded SKIP LOCKED batches so a purge never blocks live traffic, never holds a long transaction, and concurrent maintenance replicas do not contend. Indexes back each predicate so a pass cannot degrade into a sequential scan of the table it is bounding. The maintenance role reports rows purged, the backlog past its window (deletion lag), and any dead-lettered events. Also make the migration-rollback test derive its step counts instead of hardcoding them: adding a migration silently shifted the fixed counts so the failure surfaced as an unrelated "0006 rollback did not drop matches.allocation_id".
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -1792,11 +1793,13 @@ func TestPostgreSQLMigrationsRollBackAndReapplyCleanly(t *testing.T) {
|
||||
// Roll back every migration one at a time, in reverse, checking each
|
||||
// down file actually undoes what its forward file created — not just
|
||||
// that Rollback returns nil.
|
||||
// This count is the number of migrations above 0006, so it must grow with
|
||||
// every new migration; otherwise the later fixed-count rollbacks below
|
||||
// silently target the wrong files.
|
||||
if err := migrations.Rollback(context.Background(), db, dir, 8); err != nil {
|
||||
t.Fatalf("rollback 0014 through 0007: %v", err)
|
||||
// Derived, not hardcoded: every added migration shifts this count, and a
|
||||
// stale literal silently makes the fixed-count rollbacks below target the
|
||||
// wrong files (the failure then surfaces as a confusing "0006 rollback did
|
||||
// not drop matches.allocation_id").
|
||||
aboveMigration0006 := countMigrationsAbove(t, dir, 6)
|
||||
if err := migrations.Rollback(context.Background(), db, dir, aboveMigration0006); err != nil {
|
||||
t.Fatalf("rollback everything above 0006: %v", err)
|
||||
}
|
||||
var hasInitialConnectReadyColumn bool
|
||||
if err := db.QueryRow(`SELECT count(*) > 0 FROM information_schema.columns WHERE table_name = 'matches' AND column_name = 'initial_connect_ready_at'`).Scan(&hasInitialConnectReadyColumn); err != nil {
|
||||
@@ -2074,3 +2077,110 @@ func TestNotifyControlPlaneEventRejectsOversizedPayloads(t *testing.T) {
|
||||
t.Fatal("payload over the NOTIFY limit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Each 10s queue heartbeat mints a fresh idempotency key and permanently
|
||||
// inserts a row; published outbox rows and expired sessions were never purged
|
||||
// either. At 10,000 queued players heartbeats alone add roughly 60,000 durable
|
||||
// rows per minute, so table and index growth, vacuum pressure, backup size and
|
||||
// recovery time were all unbounded on a horizontally-scaled service.
|
||||
func TestPostgreSQLRetentionPurgesExpiredRecordsInBoundedBatches(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
stale := now.Add(-IdempotencyKeyRetention - time.Hour)
|
||||
fresh := now.Add(-time.Minute)
|
||||
|
||||
// 120 stale keys (purgeable) and 5 fresh ones (must survive: a client may
|
||||
// still retry those mutations).
|
||||
for i := 0; i < 120; i++ {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO idempotency_keys (scope, idempotency_key, payload_digest, result, created_at) VALUES ('queue.mutate', $1, '\x00', '{}'::jsonb, $2)`,
|
||||
fmt.Sprintf("stale-key-%030d", i), stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO idempotency_keys (scope, idempotency_key, payload_digest, result, created_at) VALUES ('queue.mutate', $1, '\x00', '{}'::jsonb, $2)`,
|
||||
fmt.Sprintf("fresh-key-%030d", i), fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
countKeys := func() int {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM idempotency_keys`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
if countKeys() != 125 {
|
||||
t.Fatalf("seed failed: %d keys", countKeys())
|
||||
}
|
||||
|
||||
// One pass must delete at most the batch size, not the whole backlog: a
|
||||
// purge that took an unbounded lock would stall live traffic.
|
||||
report, err := PurgeExpiredRecords(ctx, db, now, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("purge: %v", err)
|
||||
}
|
||||
if report.IdempotencyKeys != 50 {
|
||||
t.Fatalf("first pass deleted %d keys, want the 50-row batch bound", report.IdempotencyKeys)
|
||||
}
|
||||
if countKeys() != 75 {
|
||||
t.Fatalf("after one bounded pass: %d keys", countKeys())
|
||||
}
|
||||
|
||||
// Repeated passes converge on exactly the fresh rows and then stop.
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := PurgeExpiredRecords(ctx, db, now, 50); err != nil {
|
||||
t.Fatalf("purge pass %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if countKeys() != 5 {
|
||||
t.Fatalf("steady state left %d keys, want only the 5 fresh ones", countKeys())
|
||||
}
|
||||
final, err := PurgeExpiredRecords(ctx, db, now, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("final purge: %v", err)
|
||||
}
|
||||
if final.Total() != 0 {
|
||||
t.Fatalf("purge deleted %d rows that were still within their retention window", final.Total())
|
||||
}
|
||||
backlog, err := RetentionBacklog(ctx, db, now)
|
||||
if err != nil {
|
||||
t.Fatalf("backlog: %v", err)
|
||||
}
|
||||
if backlog != 0 {
|
||||
t.Fatalf("deletion lag is %d after draining the backlog", backlog)
|
||||
}
|
||||
}
|
||||
|
||||
// countMigrationsAbove reports how many forward migrations have a number
|
||||
// greater than the given one, so rollback step counts in this file track new
|
||||
// migrations automatically instead of needing a manual bump.
|
||||
func countMigrationsAbove(t *testing.T, dir string, number int) int {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations: %v", err)
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".sql") || len(name) < 4 {
|
||||
continue
|
||||
}
|
||||
index, err := strconv.Atoi(name[:4])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if index > number {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("no migrations found above %04d in %s", number, dir)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user