mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 03:13:56 +00:00
feat(leaderboard): roll a guess up through the region tree
A guess is credited to the district its panorama sits in, then to that district's province, then to Vietnam. Each level keeps its own board, so a player can top District 7 without touching the national table. Every existing point survives untouched. The country maps to the leaderboard:vietnam and distance:vietnam keys that already exist rather than to a new leaderboard:city:vn, so the national board keeps accumulating instead of restarting. The ':city:' segment in the key namespace is now a misnomer -- it holds district and province codes alike -- but renaming it to ':region:' would strand every key holding a player's history, so it stays, with a comment recording why. Da Lat and Duc Hoa keep their bare codes for the same reason. Ha Noi, Da Nang and Ho Chi Minh keep their totals on the province: those points predate districts and cannot be attributed to one, so their boards stay continuous while district boards start at zero. Levels are written in parallel. They are independent keys and none reads another's state, so serialising them would add two round trips of latency to every guess for nothing. Region codes and the leaderboard limit are validated inside the library rather than only in the routes. The key builder lowercases whatever it is handed straight into a Redis key, and the routes are not its only callers -- the migration script and anything added later bypass them entirely. The adapter gains scanKeys, which the migration needs to enumerate what it is about to touch. It applies the key prefix to the pattern and strips it from the results, because a caller that scanned 'leaderboard:*' directly would match nothing at all: every physical key carries the prefix, and an empty result is indistinguishable from an empty database. An empty KEY_PREFIX is no longer accepted, since enumeration with no prefix would reach every other project sharing the database. The migration seeds Lam Dong and Long An from Da Lat and Duc Hoa, whose history they inherit. It runs after the deploy, not before: migrating first leaves a window where a Da Lat guess credits the town and the country but not the province. It copies absolute scores so a second run converges rather than doubling, and empties the destination first so a player trimmed out of the source cannot survive in the copy with a stale score. An empty source is refused before that delete -- otherwise the destination is wiped and nothing written back, which reads as a clean no-op. Afterwards it confirms each destination matches its source and that nothing else went backwards, accepting that boards grow while the app serves traffic. Its backup can be restored through the same script. The migration logic lives in scripts/lib/ so its guards are reachable from a test rather than only by running it against a live database.
This commit is contained in:
1 parent
b7f8879fe0
commit
4770de51d4
10 files changed
+907
-121
No files matched your search
@@ -0,0 +1,161 @@
|
||||
// Backfill logic for the leaderboard migration.
|
||||
//
|
||||
// Separated from the CLI in scripts/migrate-leaderboards.mjs so every guard is
|
||||
// callable from a test. This is the one place in the region-tree change where
|
||||
// existing player points can be destroyed, and a script whose safety checks are
|
||||
// only reachable by running it against a live database is not testable at all.
|
||||
|
||||
import {
|
||||
scanKeys,
|
||||
zAdd,
|
||||
zRangeWithScores,
|
||||
zRemRangeByRank,
|
||||
} from '../../src/lib/upstash.js';
|
||||
import { leaderboardKeys } from '../../src/lib/leaderboard.js';
|
||||
|
||||
// child -> parent. Lam Dong and Long An are new nodes whose only child already
|
||||
// holds the full history.
|
||||
export const BACKFILLS = [
|
||||
{ from: 'DL', to: 'LD', label: 'Da Lat -> Lam Dong' },
|
||||
{ from: 'DH', to: 'LA', label: 'Duc Hoa -> Long An' },
|
||||
];
|
||||
|
||||
export const PATTERNS = ['leaderboard:*', 'distance:*'];
|
||||
|
||||
/**
|
||||
* Every key each backfill pair touches, score and distance.
|
||||
*
|
||||
* Built through the same key functions the app uses, so a change to key naming
|
||||
* cannot leave the migration writing to the old names.
|
||||
* @returns {Array<{fromKey: string, toKey: string, label: string}>} Pairs.
|
||||
*/
|
||||
export function backfillPairs() {
|
||||
return BACKFILLS.flatMap(({ from, to, label }) => [
|
||||
{ fromKey: leaderboardKeys.score(from), toKey: leaderboardKeys.score(to), label },
|
||||
{ fromKey: leaderboardKeys.distance(from), toKey: leaderboardKeys.distance(to), label },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every leaderboard key with its members and scores.
|
||||
* @param {Object} h Upstash handle.
|
||||
* @returns {Promise<Object>} Logical key -> [{value, score}].
|
||||
*/
|
||||
export async function exportAll(h) {
|
||||
const snapshot = {};
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const key of await scanKeys(h, pattern)) {
|
||||
snapshot[key] = await zRangeWithScores(h, key, 0, -1, false);
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy one sorted set over another, replacing what was there.
|
||||
*
|
||||
* Absolute scores, not increments, so re-running converges instead of doubling.
|
||||
* The destination is emptied first: writing member-by-member would leave behind
|
||||
* anyone present only in the destination, which the 200-entry trim makes
|
||||
* reachable -- a player can be trimmed out of the source while surviving in the
|
||||
* copy, and would then keep a stale score forever.
|
||||
*
|
||||
* An empty source is refused BEFORE the delete. Otherwise this wipes the
|
||||
* destination and writes nothing back, which is indistinguishable from a
|
||||
* successful no-op in the output and destroys real points.
|
||||
* @param {Object} h Upstash handle.
|
||||
* @param {string} fromKey Source logical key.
|
||||
* @param {string} toKey Destination logical key.
|
||||
* @param {boolean} apply False to report without writing.
|
||||
* @returns {Promise<{members: number, replaced: number, skipped: boolean}>}
|
||||
*/
|
||||
export async function copySortedSet(h, fromKey, toKey, apply) {
|
||||
const source = await zRangeWithScores(h, fromKey, 0, -1, false);
|
||||
const destination = await zRangeWithScores(h, toKey, 0, -1, false);
|
||||
|
||||
if (source.length === 0) {
|
||||
if (destination.length > 0) {
|
||||
throw new Error(
|
||||
`${fromKey} is empty but ${toKey} holds ${destination.length} members. ` +
|
||||
'Copying would delete them. Refusing: check KEY_PREFIX and the source key.'
|
||||
);
|
||||
}
|
||||
// Both empty is the ordinary case for a board nobody has played yet.
|
||||
return { members: 0, replaced: 0, skipped: true };
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
await zRemRangeByRank(h, toKey, 0, -1);
|
||||
for (const { value, score } of source) await zAdd(h, toKey, score, value);
|
||||
}
|
||||
return { members: source.length, replaced: destination.length, skipped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the backfill landed: each destination must now equal its source.
|
||||
*
|
||||
* The earlier version verified only the keys it did NOT write, which meant the
|
||||
* copy itself was never checked.
|
||||
* @param {Object} h Upstash handle.
|
||||
* @returns {Promise<string[]>} Pairs that disagree, empty when all match.
|
||||
*/
|
||||
export async function verifyTargets(h) {
|
||||
const mismatched = [];
|
||||
for (const { fromKey, toKey } of backfillPairs()) {
|
||||
const source = await zRangeWithScores(h, fromKey, 0, -1, false);
|
||||
const destination = await zRangeWithScores(h, toKey, 0, -1, false);
|
||||
if (JSON.stringify(source) !== JSON.stringify(destination)) {
|
||||
mismatched.push(`${toKey} does not match ${fromKey}`);
|
||||
}
|
||||
}
|
||||
return mismatched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that no untouched key lost anything while the migration ran.
|
||||
*
|
||||
* Ordering is deploy-then-migrate, so the app is serving guesses throughout and
|
||||
* boards legitimately grow mid-run. Demanding byte-equality would throw on a
|
||||
* healthy migration the moment one player scores. Forward-only drift -- members
|
||||
* added, scores never reduced, nobody removed -- is expected; anything else is
|
||||
* not.
|
||||
* @param {Object} before Snapshot taken before the writes.
|
||||
* @param {Object} after Snapshot taken after.
|
||||
* @returns {string[]} Keys that regressed, empty when all are intact.
|
||||
*/
|
||||
export function findRegressions(before, after) {
|
||||
const targets = new Set(backfillPairs().map(({ toKey }) => toKey));
|
||||
const regressed = [];
|
||||
|
||||
for (const [key, entries] of Object.entries(before)) {
|
||||
if (targets.has(key)) continue;
|
||||
const now = after[key];
|
||||
if (!now) {
|
||||
regressed.push(`${key} disappeared`);
|
||||
continue;
|
||||
}
|
||||
const nowByMember = new Map(now.map((e) => [e.value, e.score]));
|
||||
for (const { value, score } of entries) {
|
||||
if (!nowByMember.has(value)) {
|
||||
regressed.push(`${key}: member ${value} removed`);
|
||||
} else if (nowByMember.get(value) < score) {
|
||||
regressed.push(`${key}: ${value} fell from ${score} to ${nowByMember.get(value)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return regressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a snapshot, making the backup an actual rollback rather than a file.
|
||||
* @param {Object} h Upstash handle.
|
||||
* @param {Object} snapshot Output of exportAll.
|
||||
* @returns {Promise<number>} Keys restored.
|
||||
*/
|
||||
export async function restore(h, snapshot) {
|
||||
for (const [key, entries] of Object.entries(snapshot)) {
|
||||
await zRemRangeByRank(h, key, 0, -1);
|
||||
for (const { value, score } of entries) await zAdd(h, key, score, value);
|
||||
}
|
||||
return Object.keys(snapshot).length;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Seed the two new province leaderboards from the towns they inherited.
|
||||
//
|
||||
// node scripts/migrate-leaderboards.mjs # dry run
|
||||
// node scripts/migrate-leaderboards.mjs --apply --confirm-prefix=vngeoguessr:
|
||||
// node scripts/migrate-leaderboards.mjs --restore=<backup.json> --confirm-prefix=...
|
||||
//
|
||||
// Lam Dong and Long An are new nodes. Their only child -- Da Lat and Duc Hoa --
|
||||
// already holds the full history, so without a backfill each province would
|
||||
// disagree with its own single child from day one.
|
||||
//
|
||||
// RUN THIS AFTER DEPLOYING THE FAN-OUT, not before. Migrating first opens a
|
||||
// window where a Da Lat guess credits DL and Vietnam but not Lam Dong, and the
|
||||
// province is permanently short by that window. Deploying first is safe: a
|
||||
// pre-migration LD only accumulates a subset of DL's deltas, which the copy
|
||||
// then overwrites.
|
||||
//
|
||||
// Nothing else is touched. Ha Noi, Da Nang and Ho Chi Minh keep their history
|
||||
// where it is: those points predate districts and cannot be attributed to one.
|
||||
//
|
||||
// The logic lives in scripts/lib/leaderboard-migration.mjs so its guards are
|
||||
// testable; this file is argument handling and reporting.
|
||||
|
||||
import { writeFileSync, readFileSync } from 'node:fs';
|
||||
import { getUpstash } from '../src/lib/upstash.js';
|
||||
import {
|
||||
backfillPairs,
|
||||
copySortedSet,
|
||||
exportAll,
|
||||
findRegressions,
|
||||
restore,
|
||||
verifyTargets,
|
||||
} from './lib/leaderboard-migration.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const apply = args.includes('--apply');
|
||||
const dryRunFlag = args.includes('--dry-run');
|
||||
const restorePath = args.find((arg) => arg.startsWith('--restore='))?.split('=')[1];
|
||||
const confirmed = args.find((arg) => arg.startsWith('--confirm-prefix='))?.split('=')[1];
|
||||
|
||||
if (dryRunFlag && apply) {
|
||||
// Typing both is a reasonable belt-and-braces instinct, and silently writing
|
||||
// would be the worst possible answer to it.
|
||||
throw new Error('--dry-run and --apply are mutually exclusive');
|
||||
}
|
||||
|
||||
const h = getUpstash();
|
||||
const writing = apply || Boolean(restorePath);
|
||||
console.log(`key prefix: ${JSON.stringify(h.prefix)}`);
|
||||
console.log(writing ? 'mode: WRITES ENABLED' : 'mode: dry run (no writes)');
|
||||
|
||||
if (writing && confirmed !== h.prefix) {
|
||||
// The prefix decides which namespace this touches, and getting it wrong is
|
||||
// silent: every read returns empty and every write lands somewhere nothing
|
||||
// reads. Make the operator name it.
|
||||
throw new Error(
|
||||
`Refusing to write: pass --confirm-prefix=${h.prefix} to confirm the target namespace` +
|
||||
(confirmed !== undefined ? ` (got ${JSON.stringify(confirmed)})` : '')
|
||||
);
|
||||
}
|
||||
|
||||
/** Put a snapshot back. The backup is only a rollback if something reads it. */
|
||||
async function runRestore(path) {
|
||||
const snapshot = JSON.parse(readFileSync(path, 'utf8'));
|
||||
const count = await restore(h, snapshot);
|
||||
console.log(`restored ${count} keys from ${path}`);
|
||||
}
|
||||
|
||||
/** Back-fill the two new province boards from their single child. */
|
||||
async function runBackfill() {
|
||||
const before = await exportAll(h);
|
||||
const keyCount = Object.keys(before).length;
|
||||
console.log(`exported ${keyCount} leaderboard keys`);
|
||||
|
||||
if (keyCount === 0) {
|
||||
// An empty export is far more likely to be a wrong prefix than an empty
|
||||
// database, and it would make the backup -- the only rollback -- worthless.
|
||||
throw new Error(
|
||||
'Export found no leaderboard keys. That usually means KEY_PREFIX does not ' +
|
||||
'match the deployment. Refusing to continue: the backup would be empty.'
|
||||
);
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = `leaderboard-backup-${stamp}.json`;
|
||||
writeFileSync(backupPath, JSON.stringify(before, null, 1) + '\n');
|
||||
console.log(`backup -> ${backupPath} (restore with --restore=${backupPath})`);
|
||||
|
||||
console.log('');
|
||||
for (const { fromKey, toKey, label } of backfillPairs()) {
|
||||
const { members, replaced, skipped } = await copySortedSet(h, fromKey, toKey, apply);
|
||||
console.log(
|
||||
`${label.padEnd(20)} ${fromKey} -> ${toKey}: ` +
|
||||
(skipped ? 'both empty, nothing to do' : `${members} members`) +
|
||||
(replaced ? `, replacing ${replaced} already there` : '') +
|
||||
(apply || skipped ? '' : ' [dry run]')
|
||||
);
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log(`\nNothing written. Re-run with --apply --confirm-prefix=${h.prefix}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Two checks, because they catch different failures: the destinations must
|
||||
// now match their sources, and nothing else may have gone backwards.
|
||||
const mismatched = await verifyTargets(h);
|
||||
if (mismatched.length > 0) {
|
||||
throw new Error(`Backfill did not land: ${mismatched.join('; ')}`);
|
||||
}
|
||||
|
||||
const regressed = findRegressions(before, await exportAll(h));
|
||||
if (regressed.length > 0) {
|
||||
throw new Error(
|
||||
`Existing data regressed: ${regressed.join('; ')}. ` +
|
||||
`Restore with --restore=${backupPath} --confirm-prefix=${h.prefix}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\napplied. Backfill verified, and ${keyCount} pre-existing keys intact.`);
|
||||
}
|
||||
|
||||
if (restorePath) await runRestore(restorePath);
|
||||
else await runBackfill();
|
||||
Reference in new issue
Block a user