Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/schema-upgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ jobs:
- name: Apply 006 then verify the seeded-data upgrade
working-directory: database
run: bash ../scripts/with-test-timescale.sh --profile baseline-006 -- go test ./schema -run '^TestWorkerAPIUpgrade$' -count=1 -v
- name: Apply 013 then verify the army-family compatibility upgrade
working-directory: database
run: bash ../scripts/with-test-timescale.sh --profile baseline-013 -- go test ./schema -run '^TestArmyCodeFamilyCompatibilityMigration$' -count=1 -v
14 changes: 12 additions & 2 deletions RETAINED_API_FIXTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Retained API database fixture

The canonical Goose sequence is `database/timescale/001` through `011`.
The canonical Goose sequence is `database/timescale/001` through `015`.
Production was confirmed by the user to be at 006 before consolidation; files
001–006 are unchanged. `007_worker_api.sql` combines the unapplied Worker/API
migrations formerly numbered 007, 008, 009, 010, 012, 013, 020, 022, 023, 027 and
Expand All @@ -12,7 +12,7 @@ AI-budget mutex, subject mutex, resource ownership ledger and Discord delivery r
`bash scripts/with-test-timescale.sh --profile retained-api -- COMMAND [ARG ...]`
applies the complete canonical sequence to a fresh local tmpfs Timescale
container. The API's existing retained-api invocation is unchanged.
`--profile baseline-006` applies only the immutable baseline for upgrade tests:
`--profile baseline-006` applies only the immutable baseline for Worker/API upgrade tests. `--profile baseline-013` applies through the deployed battle identity so migration 014 cleanup 015 conversion and rollback behavior can be tested with populated data:

Cross-process tests that also require Valkey use the repository-owned wrapper:

Expand All @@ -30,6 +30,7 @@ never reads or reuses retained Valkey coordinates.
node --test scripts/with-test-timescale.test.mjs scripts/with-test-integration.test.mjs
cd database
bash ../scripts/with-test-timescale.sh --profile baseline-006 -- go test ./schema -run TestWorkerAPIUpgrade -count=1 -v
bash ../scripts/with-test-timescale.sh --profile baseline-013 -- go test ./schema -run TestArmyCodeFamilyCompatibilityMigration -count=1 -v
```

Both profiles reject remote Docker hosts, use deterministic test credentials and
Expand All @@ -41,6 +42,15 @@ Old disposable fixtures at any prior 007–028 version must be recreated through
the harness. Do not rename Goose history rows or apply this consolidation over
such a database. Any persistent installation beyond 006 needs separate review.


Migrations 014 and 015 are intentionally not one automatic production step.
Apply 014, pause the old battle writer, run the default-dry-run
`database/migrations/clear_ranked_defense_loot.go` companion until it verifies
zero remaining defense rows, then apply 015 and deploy compatible binaries before
resuming ingestion. The companion requires Goose version 14 exactly, refuses
compressed chunks, uses bounded committed batches, and never changes attack
loot. No fixture run is evidence that this sequence has run in production.

007 leaves all 006 ticket tables and data unchanged. Stable panel/button IDs,
editable names, archival, and stricter component validation remain deferred
until the Bot rewrite resolves legacy Discord component IDs and duplicate
Expand Down
15 changes: 15 additions & 0 deletions database/migrations/clear_ranked_defense_loot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build ignore

package main

import (
"context"

"github.com/ClashKingInc/DevKit/database/migrations/migrateutil"
)

func main() {
migrateutil.Main("clear_ranked_defense_loot", func(ctx context.Context, cfg migrateutil.Config) error {
return migrateutil.ClearRankedDefenseLoot(ctx, cfg)
})
}
132 changes: 132 additions & 0 deletions database/migrations/migrateutil/ranked_defense_loot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package migrateutil

import (
"context"
"fmt"
"strconv"
)

func rankedDefenseLootSettings(env map[string]string) (bool, int, string, error) {
apply := env["RANKED_DEFENSE_LOOT_CLEANUP_APPLY"] == "true"
if apply && env["RANKED_BATTLELOG_WRITERS_PAUSED"] != "true" {
return false, 0, "", fmt.Errorf("apply requires RANKED_BATTLELOG_WRITERS_PAUSED=true")
}
expectedDatabase := env["RANKED_DEFENSE_LOOT_CLEANUP_DATABASE"]
if apply && expectedDatabase == "" {
return false, 0, "", fmt.Errorf("apply requires RANKED_DEFENSE_LOOT_CLEANUP_DATABASE")
}
batch := 1000
if raw := env["RANKED_DEFENSE_LOOT_CLEANUP_BATCH_SIZE"]; raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 10000 {
return false, 0, "", fmt.Errorf("batch size must be 1..10000")
}
batch = value
}
return apply, batch, expectedDatabase, nil
}

// ClearRankedDefenseLoot clears only existing ranked defense loot in bounded,
// committed batches. Rerunning resumes from rows that still have non-null loot.
func ClearRankedDefenseLoot(ctx context.Context, cfg Config) error {
apply, batch, expectedDatabase, err := rankedDefenseLootSettings(cfg.Env)
if err != nil {
return err
}
pool, err := TimescalePool(ctx, cfg)
if err != nil {
return err
}
defer pool.Close()
conn, err := pool.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
var database string
if err = conn.QueryRow(ctx, "SELECT current_database()").Scan(&database); err != nil {
return err
}
if apply && database != expectedDatabase {
return fmt.Errorf("cleanup target is %q, expected %q", database, expectedDatabase)
}
if _, err = conn.Exec(ctx, "SET application_name='ck_ranked_defense_loot_cleanup'; SET lock_timeout='5s'; SET statement_timeout='120s'"); err != nil {
return err
}
var locked bool
if err = conn.QueryRow(ctx, "SELECT pg_try_advisory_lock(719233813)").Scan(&locked); err != nil {
return err
}
if !locked {
return fmt.Errorf("another ranked defense loot cleanup is running")
}
defer conn.Exec(context.Background(), "SELECT pg_advisory_unlock(719233813)")

var version int64
if err = conn.QueryRow(ctx, "SELECT max(version_id) FROM goose_db_version WHERE is_applied").Scan(&version); err != nil {
return err
}
if version != 14 {
return fmt.Errorf("cleanup requires Goose version 14 exactly; current version is %d", version)
}

var compressedChunks int64
if err = conn.QueryRow(ctx, `SELECT count(*)
FROM timescaledb_information.chunks
WHERE hypertable_schema='public' AND hypertable_name='battles_ranked'
AND is_compressed`).Scan(&compressedChunks); err != nil {
return err
}
if compressedChunks != 0 {
return fmt.Errorf("cleanup refused: battles_ranked has %d compressed chunks", compressedChunks)
}

var candidates int64
if err = conn.QueryRow(ctx, `SELECT count(*) FROM public.battles_ranked
WHERE direction='defense' AND looted_resources IS NOT NULL`).Scan(&candidates); err != nil {
return err
}
fmt.Printf("dry_run=%t defense_rows=%d batch_size=%d compressed_chunks=0\n", !apply, candidates, batch)
if !apply {
return nil
}

var cleared int64
for {
var count int64
err = conn.QueryRow(ctx, `WITH batch AS (
SELECT player_tag,battle_time
FROM public.battles_ranked
WHERE direction='defense' AND looted_resources IS NOT NULL
ORDER BY battle_time,player_tag
LIMIT $1
FOR UPDATE SKIP LOCKED
), cleared AS (
UPDATE public.battles_ranked battle
SET looted_resources=NULL
FROM batch
WHERE (battle.player_tag,battle.battle_time)
= (batch.player_tag,batch.battle_time)
RETURNING 1
)
SELECT count(*) FROM cleared`, batch).Scan(&count)
if err != nil {
return fmt.Errorf("cleanup stopped after %d committed rows: %w", cleared, err)
}
cleared += count
fmt.Printf("cleared=%d remaining_at_start=%d\n", cleared, candidates-cleared)
if count == 0 {
break
}
}
var remaining int64
if err = conn.QueryRow(ctx, `SELECT count(*) FROM public.battles_ranked
WHERE direction='defense' AND looted_resources IS NOT NULL`).Scan(&remaining); err != nil {
return err
}
if remaining != 0 {
return fmt.Errorf("cleanup incomplete: %d defense rows still contain loot", remaining)
}
fmt.Printf("cleanup complete: cleared=%d remaining=0\n", cleared)
return nil
}
31 changes: 31 additions & 0 deletions database/migrations/migrateutil/ranked_defense_loot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package migrateutil

import "testing"

func TestRankedDefenseLootSettings(t *testing.T) {
apply, batch, database, err := rankedDefenseLootSettings(nil)
if err != nil || apply || batch != 1000 || database != "" {
t.Fatal(apply, batch, database, err)
}
if _, _, _, err = rankedDefenseLootSettings(map[string]string{
"RANKED_DEFENSE_LOOT_CLEANUP_APPLY": "true",
}); err == nil {
t.Fatal("apply accepted without paused writers")
}
apply, batch, database, err = rankedDefenseLootSettings(map[string]string{
"RANKED_DEFENSE_LOOT_CLEANUP_APPLY": "true",
"RANKED_BATTLELOG_WRITERS_PAUSED": "true",
"RANKED_DEFENSE_LOOT_CLEANUP_BATCH_SIZE": "25",
"RANKED_DEFENSE_LOOT_CLEANUP_DATABASE": "clashking_test",
})
if err != nil || !apply || batch != 25 || database != "clashking_test" {
t.Fatal(apply, batch, database, err)
}
for _, value := range []string{"0", "10001", "bad"} {
if _, _, _, err = rankedDefenseLootSettings(map[string]string{
"RANKED_DEFENSE_LOOT_CLEANUP_BATCH_SIZE": value,
}); err == nil {
t.Fatal(value)
}
}
}
2 changes: 2 additions & 0 deletions database/migrations/war_archive_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) {
"011_active_verified_players.sql",
"012_legend_only_army_compositions.sql",
"013_battle_player_time_identity.sql",
"014_ranked_defense_loot_nullable.sql",
"015_army_code_family_compatibility.sql",
}
if len(files) != len(want) {
t.Fatalf("Timescale migrations = %v, want %v", files, want)
Expand Down
Loading
Loading