고빈도 저장 ID를 64비트로 확장하고 랭킹 갱신의 시퀀스 낭비를 막는다
This commit is contained in:
1 parent
087517821e
commit
bc54b157c1
25 files changed
+518
-86
No files matched your search
@@ -86,3 +86,17 @@ export const parseJson = <T>(raw: string | null): T | null => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Preserve numeric API cursors beyond int4 without silently rounding a bigint. */
|
||||
export const toSafeInteger = (value: bigint | number): number => {
|
||||
const result = Number(value);
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new RangeError('Integer exceeds the exact numeric transport range');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const withSafeIntegerId = <Row extends { id: bigint | number }>(row: Row): Omit<Row, 'id'> & { id: number } => ({
|
||||
...row,
|
||||
id: toSafeInteger(row.id),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { toSafeInteger, withSafeIntegerId } from '../src/util/parse.js';
|
||||
|
||||
describe('exact numeric ID transport', () => {
|
||||
it('preserves IDs above int4 through the maximum exact JS integer', () => {
|
||||
expect(toSafeInteger(2_147_483_648n)).toBe(2_147_483_648);
|
||||
expect(toSafeInteger(9_007_199_254_740_991n)).toBe(Number.MAX_SAFE_INTEGER);
|
||||
expect(withSafeIntegerId({ id: 2_147_483_648n, text: 'record' })).toEqual({
|
||||
id: 2_147_483_648,
|
||||
text: 'record',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects silent precision loss, fractional and nonfinite IDs', () => {
|
||||
for (const value of [9_007_199_254_740_992n, 9_007_199_254_740_993n, 1.5, NaN, Infinity]) {
|
||||
expect(() => toSafeInteger(value)).toThrow(RangeError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@
|
||||
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
||||
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
|
||||
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
|
||||
"verify:migration:rank-data": "node scripts/verify-rank-data-bigint-migration.mjs",
|
||||
"verify:migration:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh",
|
||||
"verify:migration:time-domains": "sh scripts/verify-time-domain-migration.sh",
|
||||
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
|
||||
|
||||
@@ -372,7 +372,7 @@ model NpcSelectionToken {
|
||||
}
|
||||
|
||||
model GeneralAccessLog {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
generalId Int @unique @map("general_id")
|
||||
userId String? @map("user_id")
|
||||
lastRefresh DateTime? @map("last_refresh")
|
||||
@@ -396,7 +396,7 @@ model GeneralAccessBatch {
|
||||
}
|
||||
|
||||
model TrafficPeriod {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
worldStateId Int @map("world_state_id")
|
||||
year Int
|
||||
month Int
|
||||
@@ -414,7 +414,7 @@ model TrafficPeriod {
|
||||
}
|
||||
|
||||
model TrafficPeriodGeneral {
|
||||
periodId Int @map("period_id")
|
||||
periodId BigInt @map("period_id")
|
||||
generalId Int @map("general_id")
|
||||
userId String? @map("user_id")
|
||||
refresh Int @default(0)
|
||||
@@ -482,7 +482,7 @@ model MessageAction {
|
||||
}
|
||||
|
||||
model RankData {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
nationId Int @default(0) @map("nation_id")
|
||||
generalId Int @map("general_id")
|
||||
type String @db.VarChar(20)
|
||||
@@ -679,7 +679,7 @@ model Troop {
|
||||
}
|
||||
|
||||
model GeneralTurn {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
generalId Int @map("general_id")
|
||||
turnIdx Int @map("turn_idx")
|
||||
actionCode String @map("action_code")
|
||||
@@ -701,7 +701,7 @@ model GeneralTurnRevision {
|
||||
}
|
||||
|
||||
model NationTurn {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
nationId Int @map("nation_id")
|
||||
officerLevel Int @map("officer_level")
|
||||
turnIdx Int @map("turn_idx")
|
||||
@@ -726,7 +726,7 @@ model NationTurnRevision {
|
||||
}
|
||||
|
||||
model Diplomacy {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
srcNationId Int @map("src_nation_id")
|
||||
destNationId Int @map("dest_nation_id")
|
||||
stateCode Int @map("state_code")
|
||||
@@ -790,7 +790,7 @@ model Event {
|
||||
}
|
||||
|
||||
model LogEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
serverId String? @map("server_id")
|
||||
scope LogScope
|
||||
category LogCategory
|
||||
@@ -812,7 +812,7 @@ model LogEntry {
|
||||
}
|
||||
|
||||
model ErrorLog {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
category String
|
||||
source String? @map("source")
|
||||
message String
|
||||
@@ -825,7 +825,7 @@ model ErrorLog {
|
||||
}
|
||||
|
||||
model InheritancePoint {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
key String
|
||||
value Float @default(0)
|
||||
@@ -878,7 +878,7 @@ model NationBet {
|
||||
}
|
||||
|
||||
model InheritanceLog {
|
||||
id Int @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
legacyId Int? @unique @map("legacy_id")
|
||||
userId String @map("user_id")
|
||||
serverId String? @map("server_id")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Keep all existing row IDs and the current sequence position, including an
|
||||
-- exhausted int4 sequence. Widen both ends; changing the sequence alone leaves
|
||||
-- INSERTs failing with integer overflow. Never rewind or cycle production IDs.
|
||||
BEGIN;
|
||||
ALTER TABLE "rank_data" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "rank_data_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- High churn queues/upserts and append-only logs need lifetime IDs, independent
|
||||
-- of live row count. Keep logical keys, values and sequence positions intact.
|
||||
BEGIN;
|
||||
ALTER TABLE "general_turn" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "general_turn_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
ALTER TABLE "nation_turn" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "nation_turn_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
ALTER TABLE "general_access_log" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "general_access_log_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
ALTER TABLE "diplomacy" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "diplomacy_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
ALTER TABLE "inheritance_point" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "inheritance_point_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
ALTER TABLE "log_entry" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "log_entry_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
ALTER TABLE "error_log" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "error_log_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
ALTER TABLE "inheritance_log" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "inheritance_log_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
ALTER TABLE "traffic_period" ALTER COLUMN "id" TYPE BIGINT;
|
||||
ALTER SEQUENCE "traffic_period_id_seq" AS BIGINT NO MAXVALUE NO CYCLE;
|
||||
ALTER TABLE "traffic_period_general" ALTER COLUMN "period_id" TYPE BIGINT;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import pg from 'pg';
|
||||
|
||||
const sourceUrl = process.env.RANK_DATA_MIGRATION_TEST_DATABASE_URL;
|
||||
if (!sourceUrl) throw new Error('RANK_DATA_MIGRATION_TEST_DATABASE_URL is required (isolated development DB only)');
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const targets = ['20261114000000_rank_data_bigint_id', '20261114010000_high_churn_bigint_ids'];
|
||||
const fixtures = [
|
||||
['rank_data', 'general_id, nation_id, type, value', "7, 3, 'warnum', 19"],
|
||||
['general_turn', 'general_id, turn_idx, action_code', "7, 0, 'fixture'"],
|
||||
['nation_turn', 'nation_id, officer_level, turn_idx, action_code', "3, 12, 0, 'fixture'"],
|
||||
[
|
||||
'traffic_period',
|
||||
'world_state_id, year, month, started_at, last_refresh',
|
||||
"1, 200, 1, '2026-11-14', '2026-11-14'",
|
||||
],
|
||||
['general_access_log', 'general_id', '7'],
|
||||
['diplomacy', 'src_nation_id, dest_nation_id, state_code', '3, 4, 0'],
|
||||
['inheritance_point', 'user_id, key, value', "'fixture-user', 'previous', 123"],
|
||||
['log_entry', 'scope, category, year, month, text', "'SYSTEM', 'HISTORY', 200, 1, 'fixture'"],
|
||||
['error_log', 'category, message', "'fixture', 'fixture'"],
|
||||
['inheritance_log', 'user_id, year, month, text', "'fixture-user', 200, 1, 'fixture'"],
|
||||
];
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'rank-bigint-migration-'));
|
||||
const schema = `rank_bigint_upgrade_${randomUUID().replaceAll('-', '')}`;
|
||||
const freshSchema = `${schema}_fresh`;
|
||||
const client = new pg.Client({ connectionString: sourceUrl });
|
||||
const config = path.join(packageRoot, `.rank-bigint-${process.pid}.config.mjs`);
|
||||
const url = new URL(sourceUrl);
|
||||
const deploy = (schemaName) => {
|
||||
url.searchParams.set('schema', schemaName);
|
||||
const result = spawnSync('pnpm', ['exec', 'prisma', 'migrate', 'deploy', '--config', config], {
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env, DATABASE_URL: url.href },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0) throw new Error(`Migration failed (exit ${result.status}); datasource output suppressed`);
|
||||
};
|
||||
const snapshots = new Map();
|
||||
const readIndexes = async (table) =>
|
||||
(
|
||||
await client.query(
|
||||
'SELECT indexname, indexdef FROM pg_indexes WHERE schemaname=$1 AND tablename=$2 ORDER BY indexname',
|
||||
[schema, table]
|
||||
)
|
||||
).rows;
|
||||
try {
|
||||
await fs.cp(path.join(packageRoot, 'prisma'), path.join(temp, 'prisma'), { recursive: true });
|
||||
for (const target of targets) await fs.rm(path.join(temp, 'prisma', 'migrations', target), { recursive: true });
|
||||
await fs.writeFile(
|
||||
config,
|
||||
`export default { schema: ${JSON.stringify(path.join(temp, 'prisma/game.prisma'))}, datasource: { url: process.env.DATABASE_URL } };\n`
|
||||
);
|
||||
await client.connect();
|
||||
// UUID schemas belong only to this invocation; caller's schema is never changed.
|
||||
await client.query(`CREATE SCHEMA "${schema}"`);
|
||||
await client.query(`CREATE SCHEMA "${freshSchema}"`);
|
||||
deploy(schema);
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
await client.query(`INSERT INTO world_state(id, scenario_code, current_year, current_month, tick_seconds, updated_at)
|
||||
VALUES(1, 'fixture', 200, 1, 600, CURRENT_TIMESTAMP)`);
|
||||
for (const [table, columns, values] of fixtures) {
|
||||
await client.query(`INSERT INTO "${table}" (id, ${columns}) VALUES(2147483647, ${values})`);
|
||||
await client.query(`SELECT setval('${table}_id_seq', 2147483647)`);
|
||||
await assert.rejects(client.query(`SELECT nextval('${table}_id_seq')`), { code: '2200H' });
|
||||
snapshots.set(table, {
|
||||
rows: (await client.query(`SELECT * FROM "${table}" ORDER BY id`)).rows,
|
||||
indexes: await readIndexes(table),
|
||||
});
|
||||
}
|
||||
await client.query(`INSERT INTO traffic_period_general(period_id, general_id, refresh, last_refresh)
|
||||
VALUES(2147483647, 7, 2, CURRENT_TIMESTAMP)`);
|
||||
const trafficMembers = (await client.query('SELECT * FROM traffic_period_general')).rows;
|
||||
for (const target of targets)
|
||||
await fs.cp(
|
||||
path.join(packageRoot, 'prisma', 'migrations', target),
|
||||
path.join(temp, 'prisma', 'migrations', target),
|
||||
{ recursive: true }
|
||||
);
|
||||
deploy(schema);
|
||||
for (const [table] of fixtures) {
|
||||
const before = snapshots.get(table);
|
||||
const after = (await client.query(`SELECT * FROM "${table}" ORDER BY id`)).rows;
|
||||
assert.deepEqual(
|
||||
after.map((row) => ({ ...row, id: Number(row.id) })),
|
||||
before.rows
|
||||
);
|
||||
assert.deepEqual(await readIndexes(table), before.indexes);
|
||||
assert.equal((await client.query(`SELECT last_value FROM "${table}_id_seq"`)).rows[0].last_value, '2147483647');
|
||||
assert.equal(
|
||||
(
|
||||
await client.query(
|
||||
"SELECT data_type FROM information_schema.columns WHERE table_schema=$1 AND table_name=$2 AND column_name='id'",
|
||||
[schema, table]
|
||||
)
|
||||
).rows[0].data_type,
|
||||
'bigint'
|
||||
);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await client.query(
|
||||
'SELECT data_type, max_value::text, cycle FROM pg_sequences WHERE schemaname=$1 AND sequencename=$2',
|
||||
[schema, `${table}_id_seq`]
|
||||
)
|
||||
).rows[0],
|
||||
{ data_type: 'bigint', max_value: '9223372036854775807', cycle: false }
|
||||
);
|
||||
assert.equal((await client.query(`SELECT nextval('${table}_id_seq')`)).rows[0].nextval, '2147483648');
|
||||
}
|
||||
assert.equal(
|
||||
(await client.query("INSERT INTO rank_data(general_id, type) VALUES(8, 'warnum') RETURNING id")).rows[0].id,
|
||||
'2147483649'
|
||||
);
|
||||
await assert.rejects(client.query("INSERT INTO rank_data(general_id, type) VALUES(8, 'warnum')"), {
|
||||
code: '23505',
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await client.query('SELECT * FROM traffic_period_general')).rows.map((row) => ({
|
||||
...row,
|
||||
period_id: Number(row.period_id),
|
||||
})),
|
||||
trafficMembers
|
||||
);
|
||||
await assert.rejects(
|
||||
client.query(`INSERT INTO traffic_period_general(period_id, general_id, last_refresh)
|
||||
VALUES(999, 8, CURRENT_TIMESTAMP)`),
|
||||
{ code: '23503' }
|
||||
);
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM traffic_period WHERE id=2147483647');
|
||||
assert.equal((await client.query('SELECT * FROM traffic_period_general')).rowCount, 0);
|
||||
await client.query('ROLLBACK');
|
||||
const migrations = (
|
||||
await client.query('SELECT migration_name, checksum FROM _prisma_migrations ORDER BY migration_name')
|
||||
).rows;
|
||||
deploy(schema);
|
||||
assert.deepEqual(
|
||||
(await client.query('SELECT migration_name, checksum FROM _prisma_migrations ORDER BY migration_name')).rows,
|
||||
migrations
|
||||
);
|
||||
deploy(freshSchema);
|
||||
await client.query(`SET search_path TO "${freshSchema}", public`);
|
||||
await client.query(`INSERT INTO world_state(id, scenario_code, current_year, current_month, tick_seconds, updated_at)
|
||||
VALUES(1, 'fixture', 200, 1, 600, CURRENT_TIMESTAMP)`);
|
||||
for (const [table, columns, values] of fixtures) {
|
||||
assert.equal(
|
||||
(await client.query(`INSERT INTO "${table}" (${columns}) VALUES(${values}) RETURNING id`)).rows[0].id,
|
||||
'1'
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`PASS: ${fixtures.length} exhausted int4 tables upgraded; rows/IDs/indexes preserved, bigint allocation, unique constraint, deploy no-op, fresh full migration`
|
||||
);
|
||||
} finally {
|
||||
await client.query('SET search_path TO public').catch(() => {});
|
||||
await client.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`).catch(() => {});
|
||||
await client.query(`DROP SCHEMA IF EXISTS "${freshSchema}" CASCADE`).catch(() => {});
|
||||
await client.end();
|
||||
await fs.rm(config, { force: true });
|
||||
await fs.rm(temp, { recursive: true });
|
||||
}
|
||||
Reference in new issue
Block a user