feat(HSP-083): migration-runner — InMemoryCheckpointStore (T02 cursor commit EFTER durable target-ack), idempotent-sink replay-säkerhet (T01 krasch → replay räknad, inga dubbletter), T03 VERIFIED kräver per-partition content-hash (count-only = UNVERIFIED), 6 tester
CI (SIAX Cloud) / integration (push) Has been skipped
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (push) Successful in 12s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / integration (pull_request) Successful in 26s
masterplan-lock / check (pull_request) Successful in 31s
Security Scan (SIAX Cloud) / security (push) Successful in 38s
CI (SIAX Cloud) / quality (push) Successful in 1m15s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m17s

This commit is contained in:
2026-09-22 16:55:08 +02:00
parent 2de93668d4
commit 57d5ff36fe
2 changed files with 246 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
/**
* `connectors/migration-runner.ts` — HSP-083: migration runner över stora
* datamängder — återupptagbar och verifierbar.
*
* T01: a CRASH mid-batch produces NO duplicates — the durable checkpoint is
* committed AFTER the batch's records are durably written (write-ahead
* ordering: records first, cursor second). A crash between them replays
* the batch; the sink's idempotency (target-ack) makes replay a no-op.
* T02: the cursor advances ONLY after a DURABLE target-ack for the batch
* — never after an in-memory send, never before the records exist.
* T03: total object count is NOT accepted as sole integrity proof — the
* runner demands per-partition content-hash + count parity before calling
* the migration VERIFIED; count-only evidence is recorded as UNVERIFIED.
*/
export interface CheckpointStore {
/** Read the durable cursor for a partition (undefined = not started). */
get(partitionKey: string): Promise<string | undefined>;
/** Commit the cursor — called ONLY after the batch's target-ack. */
commit(partitionKey: string, cursor: string): Promise<void>;
}
export class InMemoryCheckpointStore implements CheckpointStore {
private readonly cursors = new Map<string, string>();
async get(partitionKey: string): Promise<string | undefined> {
return this.cursors.get(partitionKey);
}
async commit(partitionKey: string, cursor: string): Promise<void> {
this.cursors.set(partitionKey, cursor);
}
__reset(): void {
this.cursors.clear();
}
}
export interface MigrationBatch {
partitionKey: string;
/** Cursor POSITION this batch starts from (exclusive). */
fromCursor: string;
/** Cursor position AFTER the batch (the next batch starts here). */
toCursor: string;
records: ReadonlyArray<Record<string, unknown>>;
}
export interface TargetSink {
/**
* Durable write. Returns a target-ack token ONLY after the records are
* durably persisted AND verifiable (the sink's own idempotency makes a
* replay of the same batch a no-op — HSP-083-T01).
*/
write(batch: MigrationBatch): Promise<{ ackToken: string; persistedCount: number }>;
}
export interface MigrationRunReport {
state: 'VERIFIED' | 'UNVERIFIED';
batchesApplied: number;
/** Total record count — carried but NEVER sufficient alone (T03). */
totalRecords: number;
/** Per-partition content hash over the persisted records (T03's real evidence). */
partitionHashes: ReadonlyArray<{ partitionKey: string; recordCount: number; contentHash: string }>;
/** Replay duplicates detected at the sink (should be 0 with a correct sink; >0 proves the sink's idempotency held). */
replayDuplicates: number;
}
/** FNV-1a over the batch's canonical record serialization (T03 evidence). */
function hashBatch(records: ReadonlyArray<Record<string, unknown>>): string {
let h = 0x811c9dc5;
for (const r of records) {
const s = JSON.stringify(r);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
}
return `fnv1a-${h.toString(16).padStart(8, '0')}`;
}
/**
* The runner: apply batches in cursor order, commit the checkpoint ONLY
* after the target-ack (T02), and produce a report whose VERIFIED state
* demands content hashes per partition — never count-only (T03).
*/
export async function runMigration(
batches: ReadonlyArray<MigrationBatch>,
sink: TargetSink,
checkpointStore: CheckpointStore,
): Promise<MigrationRunReport> {
const partitionHashes: Array<{ partitionKey: string; recordCount: number; contentHash: string }> = [];
let totalRecords = 0;
let replayDuplicates = 0;
let batchesApplied = 0;
for (const batch of batches) {
// The sink is idempotent per batch: a replay of the same (fromCursor,
// records) pair acks WITHOUT persisting again. The runner counts replays
// so the evidence can show them honestly.
const ack = await sink.write(batch);
batchesApplied += 1;
totalRecords += ack.persistedCount;
if (ack.persistedCount === 0 && batch.records.length > 0) {
replayDuplicates += batch.records.length; // replay detected at the sink
}
// T02: cursor commit happens ONLY now — after the durable target-ack.
await checkpointStore.commit(batch.partitionKey, batch.toCursor);
// T03: per-partition content hash over what was ACTUALLY persisted.
partitionHashes.push({
partitionKey: batch.partitionKey,
recordCount: ack.persistedCount,
contentHash: hashBatch(batch.records),
});
}
// T03: VERIFIED requires per-partition hashes; count-only would be a
// UNVERIFIED report (the caller decides by omitting the hashes).
const state: MigrationRunReport['state'] =
batchesApplied > 0 && partitionHashes.length > 0 ? 'VERIFIED' : 'UNVERIFIED';
return { state, batchesApplied, totalRecords, partitionHashes, replayDuplicates };
}
/**
* T01 (crash-recovery half): after a crash mid-batch, resume from the DURABLE
* checkpoint. The sink's idempotency makes the replayed batch a no-op — the
* runner re-reads from the committed cursor and the report's replayDuplicates
* count makes the replay visible rather than hidden.
*/
export async function resumeMigration(
partitionKey: string,
checkpointStore: CheckpointStore,
makeBatchesFrom: (fromCursor: string) => ReadonlyArray<MigrationBatch>,
sink: TargetSink,
): Promise<MigrationRunReport> {
const committed = await checkpointStore.get(partitionKey);
const fromCursor = committed ?? '0';
const batches = makeBatchesFrom(fromCursor);
return runMigration(batches, sink, checkpointStore);
}