-
Notifications
You must be signed in to change notification settings - Fork 0
Add SQL player leaderboard snapshots and weekly-job checkpoints #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| # Base storage correction (021) | ||
|
|
||
| All shared base data now lives on `bases`. Personal ownership remains in `user_saved_bases`. | ||
|
|
||
| | Before | After | | ||
| | --- | --- | | ||
| | base_images: base_id, position, image_url | bases.images: ordered text array | | ||
| | base_votes: base_id, user_id, vote, updated_at | bases.votes: JSON object keyed by user ID, containing vote and updatedAt | | ||
| | bases.downloads | Unchanged user ID → first-download timestamp map | | ||
|
|
||
| Example row fields: | ||
|
|
||
| ```json | ||
| { | ||
| "images": ["https://api.clashk.ing/v2/media/base_example.png"], | ||
| "votes": {"123456789012345678": {"vote": 1, "updatedAt": "2026-09-17T00:00:00Z"}}, | ||
| "downloads": {"123456789012345678": "2026-09-16T12:00:00Z"} | ||
| } | ||
| ``` | ||
|
|
||
| Migration 021 copies every image position, vote identity/direction/timestamp, and leaves downloads unchanged, then removes the two old tables. Sparse image positions retain null placeholders internally so a partially staged Discord message can resume without replacing another image. API responses omit those placeholders. Existing media objects are not copied, deleted or renamed. | ||
|
|
||
| Vote changes use an atomic update of one JSON key; image staging updates only an empty position. Repeated votes remain one vote, switching direction replaces it, and removing a vote removes only that user's key. Voter IDs stay private; Dashboard responses still expose counts and downloader identities only. | ||
|
|
||
| ## Deployment boundary | ||
|
|
||
| This is a coordinated base-storage cutover, not a rolling-compatible migration. Stop/drain old base readers and writers, apply 021 with Goose, deploy the paired API revision, then resume traffic. The migration takes exclusive locks while copying so old writes cannot be silently lost, but old API processes cannot continue using the dropped tables afterward. Do not apply it independently while the old API serves bases. No deployment is performed by this PR. | ||
|
|
||
| Down is intentionally blocked rather than silently destroying inline data. An operational rollback needs a separate data-preserving conversion and coordinated API rollback. Earlier applied migrations are unchanged. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # Migration 020: player leaderboard snapshots | ||
|
|
||
| This is an additive forward Goose migration. Existing history tables and player data are not rewritten or reset by applying it. | ||
|
|
||
| | Object | New stored data | | ||
| | --- | --- | | ||
| | player_townhall_leaderboards | Up to 500 players per TH 7–18: tag, name, townhall_level, trophies, league_id, league_group_tag, clan_tag, rank | | ||
| | player_league_leaderboards | Up to 500 players per ranked tier 105000001–105000036, with the same fields | | ||
| | tracking_scheduled_jobs | job text + period date primary key, last_tag text cursor, nullable completed_at timestamp | | ||
|
|
||
| Town Hall ordering is league descending, trophies descending, tag ascending. League ordering is trophies descending, tag ascending. Unranked and unknown leagues are excluded; ranked players with zero trophies remain eligible. The existing partial player indexes support the per-board queries. Unique board/rank indexes allow concurrent refresh after initial population. | ||
|
|
||
| Example snapshot: `#PLAYER, Example, 18, 2300, 105000034, #GROUP, #CLAN, 1`. Example job: `ranked_trophy_reset, 2026-09-21, #LASTPROCESSED, NULL` while in progress, or a completion timestamp when finished. | ||
|
|
||
| Apply 020 first, then deploy Tracking to populate both views and refresh them every six hours. Verify `pg_matviews.ispopulated` for both views before deploying the API reader. They intentionally start WITH NO DATA. If separate database roles are used, the API role needs SELECT on both views and Tracking needs refresh ownership/privileges plus read/write access to the jobs table, following the deployment's existing role policy. | ||
|
|
||
| The Tracking PR also uses the jobs table for a bounded Monday trophy reset, gated on successful closeout. This migration does not run that job. Roll back the API reader and Tracking writer before a Goose down, which removes only these three new objects. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| package schema | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestPlayerLeaderboardSnapshots(t *testing.T) { | ||
| conn := disposableConn(t) | ||
| ctx := context.Background() | ||
| tx, err := conn.Begin(ctx) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| defer tx.Rollback(ctx) | ||
| _, err = tx.Exec(ctx, ` | ||
| INSERT INTO basic_player(tag,name,townhall_level,league_id,trophies) | ||
| SELECT '#BOARD'||lpad(n::text,4,'0'),'Fixture',17,105000034,1000 FROM generate_series(1,501) n; | ||
| INSERT INTO basic_player(tag,name,townhall_level,league_id,trophies) VALUES | ||
| ('#HIGH','Higher tier',17,105000035,0),('#UNRANKED','Unranked',17,105000000,9999),('#UNKNOWN','Unknown',17,NULL,9999); | ||
| REFRESH MATERIALIZED VIEW player_townhall_leaderboards; | ||
| REFRESH MATERIALIZED VIEW player_league_leaderboards;`) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| var count int | ||
| var tag string | ||
| if err = tx.QueryRow(ctx, `SELECT count(*) FROM player_townhall_leaderboards WHERE townhall_level=17`).Scan(&count); err != nil || count != 500 { | ||
| t.Fatalf("count=%d: %v", count, err) | ||
| } | ||
| if err = tx.QueryRow(ctx, `SELECT tag FROM player_townhall_leaderboards WHERE townhall_level=17 AND rank=1`).Scan(&tag); err != nil || tag != "#HIGH" { | ||
| t.Fatalf("first=%s: %v", tag, err) | ||
| } | ||
| if err = tx.QueryRow(ctx, `SELECT tag FROM player_league_leaderboards WHERE league_id=105000034 AND rank=1`).Scan(&tag); err != nil || tag != "#BOARD0001" { | ||
| t.Fatalf("tie=%s: %v", tag, err) | ||
| } | ||
| if err = tx.QueryRow(ctx, `SELECT count(*) FROM player_league_leaderboards WHERE league_id=105000034`).Scan(&count); err != nil || count != 500 { | ||
| t.Fatalf("league count=%d: %v", count, err) | ||
| } | ||
| _, err = tx.Exec(ctx, `UPDATE basic_player SET trophies=2000 WHERE tag='#BOARD0501'`) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err = tx.QueryRow(ctx, `SELECT count(*) FROM player_league_leaderboards WHERE tag='#BOARD0501'`).Scan(&count); err != nil || count != 0 { | ||
| t.Fatalf("snapshot changed without refresh: %v", err) | ||
| } | ||
| _, err = tx.Exec(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY player_league_leaderboards`) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err = tx.QueryRow(ctx, `SELECT tag FROM player_league_leaderboards WHERE league_id=105000034 AND rank=1`).Scan(&tag); err != nil || tag != "#BOARD0501" { | ||
| t.Fatalf("refresh first=%s: %v", tag, err) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| -- +goose Up | ||
| -- Populate from Tracking after migration, before deploying the API reader. | ||
| CREATE MATERIALIZED VIEW public.player_townhall_leaderboards AS | ||
| SELECT p.*, row_number() OVER (PARTITION BY townhall_level ORDER BY league_id DESC,trophies DESC,tag)::integer AS rank | ||
| FROM generate_series(7,18) th(level) | ||
| CROSS JOIN LATERAL ( | ||
| SELECT tag,name,townhall_level,trophies,league_id,league_group_tag,clan_tag | ||
| FROM public.basic_player WHERE townhall_level=th.level AND townhall_level>=7 | ||
| AND league_id BETWEEN 105000001 AND 105000036 | ||
| AND league_id IS NOT NULL AND league_id<>105000000 | ||
| ORDER BY league_id DESC,trophies DESC,tag LIMIT 500 | ||
| ) p WITH NO DATA; | ||
| CREATE UNIQUE INDEX player_townhall_leaderboards_identity ON public.player_townhall_leaderboards(townhall_level,rank); | ||
|
|
||
| CREATE MATERIALIZED VIEW public.player_league_leaderboards AS | ||
| SELECT p.*, row_number() OVER (PARTITION BY league_id ORDER BY trophies DESC,tag)::integer AS rank | ||
| FROM generate_series(105000001,105000036) tiers(id) | ||
| CROSS JOIN LATERAL ( | ||
| SELECT tag,name,townhall_level,trophies,league_id,league_group_tag,clan_tag | ||
| FROM public.basic_player WHERE league_id=tiers.id AND league_id IS NOT NULL AND league_id<>105000000 | ||
| ORDER BY trophies DESC,tag LIMIT 500 | ||
| ) p WITH NO DATA; | ||
| CREATE UNIQUE INDEX player_league_leaderboards_identity ON public.player_league_leaderboards(league_id,rank); | ||
|
|
||
| -- Small durable control records, not player history. Completion and batch progress | ||
| -- are committed with the work they describe; reset periods are Monday UTC dates. | ||
| CREATE TABLE public.tracking_scheduled_jobs ( | ||
| job text NOT NULL, | ||
| period date NOT NULL, | ||
| last_tag text NOT NULL DEFAULT '', | ||
| completed_at timestamptz, | ||
| PRIMARY KEY(job,period) | ||
| ); | ||
|
|
||
| -- +goose Down | ||
| DROP TABLE public.tracking_scheduled_jobs; | ||
| DROP MATERIALIZED VIEW public.player_league_leaderboards; | ||
| DROP MATERIALIZED VIEW public.player_townhall_leaderboards; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| -- +goose Up | ||
| -- Lock source tables before copying so concurrent old writers cannot be lost. | ||
| LOCK TABLE public.bases, public.base_images, public.base_votes IN ACCESS EXCLUSIVE MODE; | ||
| ALTER TABLE public.bases ADD COLUMN images text[] NOT NULL DEFAULT '{}', | ||
| ADD COLUMN votes jsonb NOT NULL DEFAULT '{}'; | ||
| UPDATE public.bases b SET images=x.images FROM ( | ||
| SELECT base_id,array_agg(i.image_url ORDER BY p.position) images | ||
| FROM (SELECT base_id,max(position) last_position FROM public.base_images GROUP BY base_id) s | ||
| CROSS JOIN LATERAL generate_series(1,s.last_position) p(position) | ||
| LEFT JOIN public.base_images i USING(base_id,position) GROUP BY base_id | ||
| ) x WHERE b.id=x.base_id; | ||
| UPDATE public.bases b SET votes=x.votes FROM ( | ||
| SELECT base_id,jsonb_object_agg(user_id,jsonb_build_object('vote',vote,'updatedAt',updated_at)) votes | ||
| FROM public.base_votes GROUP BY base_id | ||
| ) x WHERE b.id=x.base_id; | ||
|
|
||
| -- Null array slots preserve positions during partial legacy-image staging. | ||
| -- +goose StatementBegin | ||
| CREATE FUNCTION public.base_images_valid(value text[]) RETURNS boolean | ||
| LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$ | ||
| SELECT cardinality(value)<=4 AND (cardinality(value)=0 OR (array_ndims(value)=1 AND array_lower(value,1)=1)) | ||
| AND NOT EXISTS(SELECT 1 FROM unnest(value) image WHERE image IS NOT NULL AND image !~ '^https://api[.]clashk[.]ing/v2/media/[A-Za-z0-9][A-Za-z0-9._-]*$') | ||
| AND (SELECT count(image)=count(DISTINCT image) FROM unnest(value) image) | ||
| $$; | ||
| CREATE FUNCTION public.base_votes_valid(value jsonb) RETURNS boolean | ||
| LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ | ||
| DECLARE entry record; parsed timestamptz; | ||
| BEGIN | ||
| IF jsonb_typeof(value)<>'object' THEN RETURN false; END IF; | ||
| FOR entry IN SELECT * FROM jsonb_each(value) LOOP | ||
| IF entry.key !~ '^[0-9]+$' OR jsonb_typeof(entry.value)<>'object' | ||
| OR NOT (entry.value ? 'vote' AND entry.value ? 'updatedAt') | ||
| OR entry.value->'vote' NOT IN ('1'::jsonb,'-1'::jsonb) | ||
| OR jsonb_typeof(entry.value->'updatedAt')<>'string' THEN RETURN false; END IF; | ||
| BEGIN parsed:=(entry.value->>'updatedAt')::timestamptz; | ||
| EXCEPTION WHEN others THEN RETURN false; END; | ||
| END LOOP; | ||
| RETURN true; | ||
| END $$; | ||
| -- +goose StatementEnd | ||
| ALTER TABLE public.bases ADD CONSTRAINT bases_images_check CHECK(public.base_images_valid(images)), | ||
| ADD CONSTRAINT bases_votes_check CHECK(public.base_votes_valid(votes)); | ||
| DROP VIEW public.base_public_counts; | ||
| CREATE VIEW public.base_public_counts AS SELECT id base_id, | ||
| (SELECT count(*) FROM jsonb_object_keys(downloads)) download_count, | ||
| (SELECT count(*) FROM jsonb_each(votes) v WHERE v.value->>'vote'='1') upvote_count, | ||
| (SELECT count(*) FROM jsonb_each(votes) v WHERE v.value->>'vote'='-1') downvote_count | ||
| FROM public.bases; | ||
| DROP TABLE public.base_images; | ||
| DROP TABLE public.base_votes; | ||
|
Comment on lines
+49
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When migration 021 is applied, these tables no longer exist, but Useful? React with 👍 / 👎. |
||
|
|
||
| -- +goose Down | ||
| -- Require coordinated reader/writer rollback; do not silently discard inline data. | ||
| -- +goose StatementBegin | ||
| DO $$ BEGIN RAISE EXCEPTION '021 requires an explicit data-preserving rollback; do not drop inline images or votes'; END $$; | ||
| -- +goose StatementEnd | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding this migration makes
TestTimescaleMigrationsAreExplicitlyNumberedindatabase/migrations/war_archive_schema_test.gofail: its glob now finds 20 SQL files, while the unchangedwantslice still ends at019_unlimited_personal_bases.sqland requires equal lengths. Add020_player_leaderboard_snapshots.sqlto that inventory so the repository's requiredgo test ./...validation can pass.Useful? React with 👍 / 👎.