feat: 내구성 있는 read model 변화 저널 기반을 추가
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/infra --watch",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node -e \"console.log('test not configured')\"",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"prisma:generate": "pnpm prisma:generate:game && pnpm prisma:generate:gateway",
|
||||
"typecheck": "pnpm -w tsc7 -b packages/infra/tsconfig.json",
|
||||
"prisma:generate:game": "prisma generate --schema prisma/game.prisma",
|
||||
@@ -28,6 +28,7 @@
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"@prisma/client-runtime-utils": "^7.9.1",
|
||||
"@sammo-ts/common": "workspace:*",
|
||||
"@sammo-ts/logic": "workspace:*",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"pg": "^8.16.3",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@types/pg": "^8.21.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"prisma": "^7.9.1",
|
||||
"tsdown": "^0.22.14"
|
||||
"tsdown": "^0.22.14",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,38 @@ model InputEvent {
|
||||
@@map("input_event")
|
||||
}
|
||||
|
||||
model ReadModelRevision {
|
||||
domain String
|
||||
entityId Int @default(0) @map("entity_id")
|
||||
revision BigInt @default(0)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@id([domain, entityId])
|
||||
@@map("read_model_revision")
|
||||
}
|
||||
|
||||
model ReadModelOutbox {
|
||||
id BigInt @id @default(autoincrement())
|
||||
payload Json
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime @default(now()) @map("available_at")
|
||||
lockedAt DateTime? @map("locked_at")
|
||||
lockOwner String? @map("lock_owner")
|
||||
deliveredAt DateTime? @map("delivered_at")
|
||||
lastError String? @map("last_error")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([deliveredAt, availableAt, id], map: "read_model_outbox_delivered_at_available_at_id_idx")
|
||||
@@map("read_model_outbox")
|
||||
}
|
||||
|
||||
model ReadModelRevisionMeta {
|
||||
id Int @id
|
||||
coverageVersion Int @default(0) @map("coverage_version")
|
||||
|
||||
@@map("read_model_revision_meta")
|
||||
}
|
||||
|
||||
model TurnDaemonLease {
|
||||
profile String @id
|
||||
ownerId String @map("owner_id")
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
CREATE TABLE "read_model_revision" (
|
||||
"domain" TEXT NOT NULL,
|
||||
"entity_id" INTEGER NOT NULL DEFAULT 0,
|
||||
"revision" BIGINT NOT NULL DEFAULT 0,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "read_model_revision_pkey" PRIMARY KEY ("domain", "entity_id"),
|
||||
CONSTRAINT "read_model_revision_entity_id_check" CHECK ("entity_id" >= 0),
|
||||
CONSTRAINT "read_model_revision_revision_check" CHECK ("revision" >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE "read_model_outbox" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"locked_at" TIMESTAMP(3),
|
||||
"lock_owner" TEXT,
|
||||
"delivered_at" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "read_model_outbox_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "read_model_outbox_attempts_check" CHECK ("attempts" >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX "read_model_outbox_delivered_at_available_at_id_idx"
|
||||
ON "read_model_outbox"("delivered_at", "available_at", "id");
|
||||
|
||||
CREATE TABLE "read_model_revision_meta" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"coverage_version" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "read_model_revision_meta_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "read_model_revision_meta_coverage_version_check" CHECK ("coverage_version" >= 0)
|
||||
);
|
||||
|
||||
INSERT INTO "read_model_revision_meta" ("id", "coverage_version")
|
||||
VALUES (1, 0);
|
||||
@@ -27,6 +27,8 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여
|
||||
- `world_state`, `nation`, `city`, `general`, `message`, `troop`
|
||||
- `general_turn`, `nation_turn`과 revision·lease field
|
||||
- `input_event`, `turn_daemon_lease`
|
||||
- `read_model_revision`, `read_model_outbox`, `read_model_revision_meta`
|
||||
- `read_model_revision_meta.id=1`의 `coverage_version=0`
|
||||
- `diplomacy`, `event`, `log_entry`, `error_log`
|
||||
- auction, board, vote, yearbook, archive와 inheritance table
|
||||
- `nation.chief_general_id`
|
||||
|
||||
@@ -6,3 +6,4 @@ export type { GatewayPrismaClient } from './gatewayPrisma.js';
|
||||
export * from './db.js';
|
||||
export * from './redis.js';
|
||||
export * from './turnEngineDb.js';
|
||||
export * from './readModelChangeJournal.js';
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
isReadModelDomain,
|
||||
normalizeReadModelRevisionKeys,
|
||||
READ_MODEL_OUTBOX_PAYLOAD_VERSION,
|
||||
type CommittedReadModelInvalidation,
|
||||
type ReadModelRevisionKey,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
import { GamePrisma } from './gamePrisma.js';
|
||||
|
||||
interface ReadModelJournalWriteRow {
|
||||
domain: string;
|
||||
entityId: number;
|
||||
revision: bigint;
|
||||
outboxId: bigint;
|
||||
}
|
||||
|
||||
export interface ReadModelJournalWriteResult {
|
||||
invalidation: CommittedReadModelInvalidation;
|
||||
/** Delivery identity only. It must not be used as a projection revision. */
|
||||
outboxId: bigint;
|
||||
}
|
||||
|
||||
const assertWriteRows = (
|
||||
keys: readonly ReadModelRevisionKey[],
|
||||
rows: readonly ReadModelJournalWriteRow[]
|
||||
): ReadModelJournalWriteResult => {
|
||||
if (rows.length !== keys.length) {
|
||||
throw new Error(`Read-model journal wrote ${rows.length} revisions for ${keys.length} keys.`);
|
||||
}
|
||||
|
||||
const first = rows[0];
|
||||
if (!first) {
|
||||
throw new Error('Read-model journal write did not return an outbox ID.');
|
||||
}
|
||||
const outboxId = BigInt(first.outboxId);
|
||||
const revisions = rows.map((row, index) => {
|
||||
const key = keys[index];
|
||||
if (!key || !isReadModelDomain(row.domain) || row.domain !== key.domain || row.entityId !== key.entityId) {
|
||||
throw new Error('Read-model journal write returned revisions in an unexpected key order.');
|
||||
}
|
||||
if (BigInt(row.outboxId) !== outboxId) {
|
||||
throw new Error('Read-model journal write returned more than one outbox row.');
|
||||
}
|
||||
return {
|
||||
domain: row.domain,
|
||||
entityId: row.entityId,
|
||||
revision: BigInt(row.revision),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
invalidation: { revisions },
|
||||
outboxId,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Atomically increments every normalized revision key and stores exactly one
|
||||
* compact outbox payload. The caller must pass the existing Prisma transaction
|
||||
* that owns the domain mutation; this function never opens or commits one.
|
||||
*/
|
||||
export const writeReadModelChangeJournal = async (
|
||||
transaction: GamePrisma.TransactionClient,
|
||||
candidates: Iterable<ReadModelRevisionKey>
|
||||
): Promise<ReadModelJournalWriteResult | null> => {
|
||||
const keys = normalizeReadModelRevisionKeys(candidates);
|
||||
if (keys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestedRows = keys.map(({ domain, entityId }) => GamePrisma.sql`(${domain}::text, ${entityId}::integer)`);
|
||||
const rows = await transaction.$queryRaw<ReadModelJournalWriteRow[]>(GamePrisma.sql`
|
||||
WITH requested("domain", "entity_id") AS (
|
||||
VALUES ${GamePrisma.join(requestedRows)}
|
||||
),
|
||||
bumped AS (
|
||||
INSERT INTO "read_model_revision" (
|
||||
"domain",
|
||||
"entity_id",
|
||||
"revision",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
requested."domain",
|
||||
requested."entity_id",
|
||||
1,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM requested
|
||||
ORDER BY requested."domain", requested."entity_id"
|
||||
ON CONFLICT ("domain", "entity_id") DO UPDATE
|
||||
SET
|
||||
"revision" = "read_model_revision"."revision" + 1,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
RETURNING "domain", "entity_id", "revision"
|
||||
),
|
||||
outbox_payload AS (
|
||||
SELECT jsonb_build_object(
|
||||
'version', ${READ_MODEL_OUTBOX_PAYLOAD_VERSION}::integer,
|
||||
'changes', jsonb_agg(
|
||||
jsonb_build_array("domain", "entity_id", "revision"::text)
|
||||
ORDER BY "domain", "entity_id"
|
||||
)
|
||||
) AS "payload"
|
||||
FROM bumped
|
||||
),
|
||||
inserted_outbox AS (
|
||||
INSERT INTO "read_model_outbox" ("payload")
|
||||
SELECT "payload" FROM outbox_payload
|
||||
RETURNING "id"
|
||||
)
|
||||
SELECT
|
||||
bumped."domain",
|
||||
bumped."entity_id" AS "entityId",
|
||||
bumped."revision",
|
||||
inserted_outbox."id" AS "outboxId"
|
||||
FROM bumped
|
||||
CROSS JOIN inserted_outbox
|
||||
ORDER BY bumped."domain", bumped."entity_id"
|
||||
`);
|
||||
|
||||
return assertWriteRows(keys, rows);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ChangeJournal } from '@sammo-ts/common';
|
||||
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '../src/gamePrisma.js';
|
||||
import { writeReadModelChangeJournal } from '../src/readModelChangeJournal.js';
|
||||
|
||||
const databaseUrl = process.env.READ_MODEL_JOURNAL_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
|
||||
integration('read-model change journal PostgreSQL boundary', () => {
|
||||
let disconnect: (() => Promise<void>) | undefined;
|
||||
let prisma: GamePrismaClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!databaseUrl) {
|
||||
throw new Error('READ_MODEL_JOURNAL_DATABASE_URL is required.');
|
||||
}
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
prisma = connector.prisma;
|
||||
disconnect = connector.disconnect;
|
||||
await connector.connect();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await disconnect?.();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.$executeRaw`TRUNCATE TABLE "read_model_outbox", "read_model_revision" RESTART IDENTITY`;
|
||||
});
|
||||
|
||||
it('commits normalized revisions and one compact outbox payload', async () => {
|
||||
const journal = new ChangeJournal().mark('map.world').mark('general.content', 7).mark('map.world');
|
||||
|
||||
const receipt = await prisma.$transaction((transaction) =>
|
||||
writeReadModelChangeJournal(transaction, journal.snapshot())
|
||||
);
|
||||
const outboxes = await prisma.readModelOutbox.findMany();
|
||||
|
||||
expect(receipt).toEqual({
|
||||
invalidation: {
|
||||
revisions: [
|
||||
{ domain: 'general.content', entityId: 7, revision: 1n },
|
||||
{ domain: 'map.world', entityId: 0, revision: 1n },
|
||||
],
|
||||
},
|
||||
outboxId: 1n,
|
||||
});
|
||||
expect(outboxes).toHaveLength(1);
|
||||
expect(outboxes[0]?.payload).toEqual({
|
||||
version: 1,
|
||||
changes: [
|
||||
['general.content', 7, '1'],
|
||||
['map.world', 0, '1'],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls revision and outbox changes back with the owner transaction', async () => {
|
||||
await expect(
|
||||
prisma.$transaction(async (transaction) => {
|
||||
await writeReadModelChangeJournal(transaction, [{ domain: 'nation.content', entityId: 2 }]);
|
||||
throw new Error('rollback fixture');
|
||||
})
|
||||
).rejects.toThrow('rollback fixture');
|
||||
|
||||
await expect(prisma.readModelRevision.count()).resolves.toBe(0);
|
||||
await expect(prisma.readModelOutbox.count()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('does not lose increments from concurrent writers of the same key', async () => {
|
||||
const writerCount = 32;
|
||||
await Promise.all(
|
||||
Array.from({ length: writerCount }, () =>
|
||||
prisma.$transaction((transaction) =>
|
||||
writeReadModelChangeJournal(transaction, [{ domain: 'world.content', entityId: 0 }])
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const revision = await prisma.readModelRevision.findUniqueOrThrow({
|
||||
where: { domain_entityId: { domain: 'world.content', entityId: 0 } },
|
||||
});
|
||||
expect(revision.revision).toBe(BigInt(writerCount));
|
||||
await expect(prisma.readModelOutbox.count()).resolves.toBe(writerCount);
|
||||
});
|
||||
|
||||
it('keeps revision-first coverage disabled by default', async () => {
|
||||
const meta = await prisma.readModelRevisionMeta.findUniqueOrThrow({ where: { id: 1 } });
|
||||
expect(meta.coverageVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('uses the Prisma-declared outbox dispatch index name', async () => {
|
||||
const indexes = await prisma.$queryRaw<Array<{ indexname: string }>>`
|
||||
SELECT "indexname"
|
||||
FROM "pg_indexes"
|
||||
WHERE "schemaname" = current_schema()
|
||||
AND "tablename" = 'read_model_outbox'
|
||||
`;
|
||||
expect(indexes.map(({ indexname }) => indexname)).toContain(
|
||||
'read_model_outbox_delivered_at_available_at_id_idx'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects negative revision, entity, retry, and coverage state at the database boundary', async () => {
|
||||
await expect(
|
||||
prisma.readModelRevision.create({
|
||||
data: { domain: 'general.content', entityId: -1 },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
prisma.readModelRevision.create({
|
||||
data: { domain: 'general.content', entityId: 1, revision: -1 },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
prisma.readModelOutbox.create({
|
||||
data: { payload: {}, attempts: -1 },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
prisma.readModelRevisionMeta.update({
|
||||
where: { id: 1 },
|
||||
data: { coverageVersion: -1 },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrisma } from '../src/gamePrisma.js';
|
||||
import { writeReadModelChangeJournal } from '../src/readModelChangeJournal.js';
|
||||
|
||||
const createTransaction = (rows: readonly object[]) => {
|
||||
const queryRaw = vi.fn().mockResolvedValue(rows);
|
||||
return {
|
||||
transaction: { $queryRaw: queryRaw } as unknown as GamePrisma.TransactionClient,
|
||||
queryRaw,
|
||||
};
|
||||
};
|
||||
|
||||
describe('writeReadModelChangeJournal', () => {
|
||||
it('dedupes keys, writes one statement, and returns committed revisions', async () => {
|
||||
const { transaction, queryRaw } = createTransaction([
|
||||
{ domain: 'general.content', entityId: 3, revision: 8n, outboxId: 41n },
|
||||
{ domain: 'map.world', entityId: 0, revision: 2n, outboxId: 41n },
|
||||
]);
|
||||
|
||||
const result = await writeReadModelChangeJournal(transaction, [
|
||||
{ domain: 'map.world', entityId: 0 },
|
||||
{ domain: 'general.content', entityId: 3 },
|
||||
{ domain: 'map.world', entityId: 0 },
|
||||
]);
|
||||
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
invalidation: {
|
||||
revisions: [
|
||||
{ domain: 'general.content', entityId: 3, revision: 8n },
|
||||
{ domain: 'map.world', entityId: 0, revision: 2n },
|
||||
],
|
||||
},
|
||||
outboxId: 41n,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch the transaction for an empty journal', async () => {
|
||||
const { transaction, queryRaw } = createTransaction([]);
|
||||
|
||||
await expect(writeReadModelChangeJournal(transaction, [])).resolves.toBeNull();
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a malformed database receipt instead of publishing partial state', async () => {
|
||||
const { transaction } = createTransaction([
|
||||
{ domain: 'general.content', entityId: 4, revision: 1n, outboxId: 1n },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
writeReadModelChangeJournal(transaction, [{ domain: 'general.content', entityId: 3 }])
|
||||
).rejects.toThrow('unexpected key order');
|
||||
});
|
||||
});
|
||||
@@ -5,5 +5,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src", "test", "*.ts"],
|
||||
"references": [{ "path": "../logic" }]
|
||||
"references": [{ "path": "../common" }, { "path": "../logic" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user