feat: dashboard source coverage를 안전하게 활성화
엔진의 부대·예약턴·집계 변화와 mailbox writer를 저널에 연결하고 dashboard.global 및 인증 아이콘 source를 dependency vector에 포함한다. post-deploy coverage v1 활성화는 shared head seed와 CAS를 한 transaction으로 수행한다.
This commit is contained in:
@@ -21,6 +21,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",
|
||||
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
|
||||
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
|
||||
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
activateReadModelRevisionCoverage,
|
||||
createGamePostgresConnector,
|
||||
resolvePostgresConfigFromEnv,
|
||||
} from '../dist/index.js';
|
||||
|
||||
const main = async () => {
|
||||
const profile = process.env.READ_MODEL_COVERAGE_PROFILE?.trim();
|
||||
const expectedConfirm = profile ? `activate:${profile}:coverage-v1` : '';
|
||||
if (!profile || process.env.READ_MODEL_COVERAGE_CONFIRM !== expectedConfirm) {
|
||||
throw new Error('confirmation');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/u.test(profile)) {
|
||||
throw new Error('profile');
|
||||
}
|
||||
|
||||
const connector = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: profile }));
|
||||
try {
|
||||
await connector.connect();
|
||||
const result = await connector.prisma.$transaction((transaction) =>
|
||||
activateReadModelRevisionCoverage(transaction)
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
profile,
|
||||
previousVersion: result.previousVersion,
|
||||
coverageVersion: result.coverageVersion,
|
||||
seededHeads: result.seededHeads,
|
||||
})}\n`
|
||||
);
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
await main().catch(() => {
|
||||
process.stderr.write(
|
||||
'Read-model coverage activation failed. Check the profile confirmation, built infra package, database connectivity, and current coverage version.\n'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -8,3 +8,4 @@ export * from './redis.js';
|
||||
export * from './turnEngineDb.js';
|
||||
export * from './readModelChangeJournal.js';
|
||||
export * from './readModelOutboxDispatcher.js';
|
||||
export * from './readModelCoverageActivation.js';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
|
||||
|
||||
export const READ_MODEL_REVISION_COVERAGE_VERSION = 1;
|
||||
|
||||
type CoverageDatabase = Pick<GamePrismaClient, '$executeRaw' | '$queryRaw'>;
|
||||
|
||||
interface CoverageRow {
|
||||
coverageVersion: number;
|
||||
}
|
||||
|
||||
export interface ReadModelCoverageActivationResult {
|
||||
previousVersion: number;
|
||||
coverageVersion: number;
|
||||
seededHeads: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction-bound post-deploy activation. The caller must run this only after
|
||||
* every writer for this binary version is deployed. It seeds shared heads
|
||||
* without overwriting concurrent increments, then raises the authority gate.
|
||||
*/
|
||||
export const activateReadModelRevisionCoverage = async (
|
||||
transaction: CoverageDatabase,
|
||||
expectedVersion = 0
|
||||
): Promise<ReadModelCoverageActivationResult> => {
|
||||
if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0) {
|
||||
throw new RangeError('Expected read-model coverage version must be a non-negative safe integer.');
|
||||
}
|
||||
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
SELECT pg_advisory_xact_lock(
|
||||
hashtext('read-model-revision-coverage'),
|
||||
${READ_MODEL_REVISION_COVERAGE_VERSION}
|
||||
)
|
||||
`);
|
||||
const rows = await transaction.$queryRaw<CoverageRow[]>(GamePrisma.sql`
|
||||
SELECT "coverage_version" AS "coverageVersion"
|
||||
FROM "read_model_revision_meta"
|
||||
WHERE "id" = 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
const current = rows.length === 1 ? rows[0]?.coverageVersion : undefined;
|
||||
if (!Number.isSafeInteger(current) || (current !== expectedVersion && current !== READ_MODEL_REVISION_COVERAGE_VERSION)) {
|
||||
throw new Error(
|
||||
`Read-model coverage activation expected ${expectedVersion} or ${READ_MODEL_REVISION_COVERAGE_VERSION}, received ${String(current)}.`
|
||||
);
|
||||
}
|
||||
|
||||
const seededHeads = await transaction.$executeRaw(GamePrisma.sql`
|
||||
INSERT INTO "read_model_revision" ("domain", "entity_id", "revision", "updated_at")
|
||||
VALUES
|
||||
('dashboard.global', 0, 1, CURRENT_TIMESTAMP),
|
||||
('map.world', 0, 1, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("domain", "entity_id") DO NOTHING
|
||||
`);
|
||||
if (current !== READ_MODEL_REVISION_COVERAGE_VERSION) {
|
||||
const updated = await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "read_model_revision_meta"
|
||||
SET "coverage_version" = ${READ_MODEL_REVISION_COVERAGE_VERSION}
|
||||
WHERE "id" = 1
|
||||
AND "coverage_version" = ${expectedVersion}
|
||||
`);
|
||||
if (updated !== 1) {
|
||||
throw new Error('Read-model coverage activation lost its version compare-and-set.');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
previousVersion: current,
|
||||
coverageVersion: READ_MODEL_REVISION_COVERAGE_VERSION,
|
||||
seededHeads,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '../src/gamePrisma.js';
|
||||
import { activateReadModelRevisionCoverage } from '../src/readModelCoverageActivation.js';
|
||||
|
||||
const databaseUrl = process.env.READ_MODEL_JOURNAL_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
|
||||
integration('read-model coverage activation PostgreSQL boundary', () => {
|
||||
let disconnect: (() => Promise<void>) | undefined;
|
||||
let prisma: GamePrismaClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
prisma = connector.prisma;
|
||||
disconnect = connector.disconnect;
|
||||
await connector.connect();
|
||||
});
|
||||
|
||||
afterAll(async () => disconnect?.());
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.$transaction(async (transaction) => {
|
||||
await transaction.readModelRevision.deleteMany();
|
||||
await transaction.readModelRevisionMeta.upsert({
|
||||
where: { id: 1 },
|
||||
create: { id: 1, coverageVersion: 0 },
|
||||
update: { coverageVersion: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds shared heads and raises coverage in one idempotent transaction', async () => {
|
||||
await prisma.readModelRevision.create({
|
||||
data: { domain: 'dashboard.global', entityId: 0, revision: 9n },
|
||||
});
|
||||
|
||||
await expect(
|
||||
prisma.$transaction((transaction) => activateReadModelRevisionCoverage(transaction))
|
||||
).resolves.toEqual({ previousVersion: 0, coverageVersion: 1, seededHeads: 1 });
|
||||
await expect(
|
||||
prisma.$transaction((transaction) => activateReadModelRevisionCoverage(transaction))
|
||||
).resolves.toEqual({ previousVersion: 1, coverageVersion: 1, seededHeads: 0 });
|
||||
|
||||
await expect(prisma.readModelRevisionMeta.findUniqueOrThrow({ where: { id: 1 } })).resolves.toMatchObject({
|
||||
coverageVersion: 1,
|
||||
});
|
||||
await expect(
|
||||
prisma.readModelRevision.findUniqueOrThrow({
|
||||
where: { domain_entityId: { domain: 'dashboard.global', entityId: 0 } },
|
||||
})
|
||||
).resolves.toMatchObject({ revision: 9n });
|
||||
await expect(
|
||||
prisma.readModelRevision.findUniqueOrThrow({
|
||||
where: { domain_entityId: { domain: 'map.world', entityId: 0 } },
|
||||
})
|
||||
).resolves.toMatchObject({ revision: 1n });
|
||||
});
|
||||
|
||||
it('rolls seeded heads and coverage back with the owner transaction', async () => {
|
||||
await expect(
|
||||
prisma.$transaction(async (transaction) => {
|
||||
await activateReadModelRevisionCoverage(transaction);
|
||||
throw new Error('rollback activation fixture');
|
||||
})
|
||||
).rejects.toThrow('rollback activation fixture');
|
||||
|
||||
await expect(prisma.readModelRevision.count()).resolves.toBe(0);
|
||||
await expect(prisma.readModelRevisionMeta.findUniqueOrThrow({ where: { id: 1 } })).resolves.toMatchObject({
|
||||
coverageVersion: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user