fix(data-plane): accept exact replacement replays

This commit is contained in:
Codex
2026-07-21 23:11:35 +03:00
parent 45884cd4dc
commit a9b8d71968
5 changed files with 279 additions and 5 deletions
@@ -104,8 +104,6 @@ export async function persistDataProductPublish(pool, batch, definition, {
return { ...normalizeReceipt(existing.rows[0]), idempotent: true };
}
if (batch.batch.mode === "replace") await lockReplacementGeneration(client, batch);
const records = batch.facts.map((fact) => ({
source_id: fact.sourceId,
semantic_type: fact.semanticType,
@@ -116,6 +114,33 @@ export async function persistDataProductPublish(pool, batch, definition, {
fingerprint: fingerprint(fact),
}));
const replacement = batch.batch.mode === "replace"
? await lockReplacementGeneration(client, batch, records)
: { disposition: "advance" };
if (replacement.disposition === "replay") {
// Transport keys may change across publisher releases. Equal replacement
// generations are idempotent only when the complete stored fact set is exact.
const cursor = await currentCursor(client, batch);
await client.query(
`update external_data_plane_batches
set delivery_cursor = $2
where id = $1`,
[batchId, cursor],
);
await client.query("commit");
return normalizeReceipt({
batchId,
idempotent: true,
publishedFactCount: batch.facts.length,
currentUpdatedCount: 0,
currentRemovedCount: 0,
historyInsertedCount: 0,
patchOperationCount: 0,
cursor,
acceptedAt: batch.batch.receivedAt,
});
}
const updated = await client.query(
`insert into external_data_plane_current (
tenant_id, connection_id, provider_id, data_product_id, source_id, semantic_type,
@@ -448,7 +473,7 @@ export async function pruneBatchReceipts(db, { retentionMs, limit = 10_000 }) {
return result.rowCount;
}
async function lockReplacementGeneration(client, batch) {
async function lockReplacementGeneration(client, batch, records) {
const scope = [batch.source.tenantId, batch.source.connectionId, batch.source.providerId, batch.contract.dataProductId];
await client.query(
`insert into external_data_plane_delivery_state (
@@ -468,9 +493,59 @@ async function lockReplacementGeneration(client, batch) {
const currentGenerationAt = state.rows[0]?.currentGenerationAt
? new Date(state.rows[0].currentGenerationAt)
: null;
if (currentGenerationAt && generationAt <= currentGenerationAt) {
if (!currentGenerationAt || generationAt > currentGenerationAt) {
return { disposition: "advance" };
}
if (generationAt < currentGenerationAt) {
throw deliveryError("data_product_generation_not_newer", 409);
}
// The delivery-state row remains locked while the complete current snapshot
// is compared, so an equal-generation replay cannot race a newer replacement.
const comparison = await client.query(
`with incoming as (
select source_id, semantic_type, fingerprint
from jsonb_to_recordset($5::jsonb) as item(
source_id text, semantic_type text, fingerprint text
)
), current_scope as (
select source_id, semantic_type, fingerprint
from external_data_plane_current
where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4
)
select
(select count(*)::integer from incoming) as "incomingCount",
(select count(*)::integer from current_scope) as "currentCount",
(select count(*)::integer
from incoming
join current_scope using (source_id, semantic_type, fingerprint)) as "matchingCount"`,
[...scope, JSON.stringify(records)],
);
const disposition = classifyReplacementGeneration({
currentGenerationAt,
generationAt,
incomingCount: comparison.rows[0]?.incomingCount,
currentCount: comparison.rows[0]?.currentCount,
matchingCount: comparison.rows[0]?.matchingCount,
});
if (disposition !== "replay") throw deliveryError("data_product_generation_not_newer", 409);
return { disposition };
}
export function classifyReplacementGeneration({
currentGenerationAt,
generationAt,
incomingCount = 0,
currentCount = 0,
matchingCount = 0,
}) {
const incomingTime = new Date(generationAt).getTime();
const currentTime = currentGenerationAt ? new Date(currentGenerationAt).getTime() : null;
if (currentTime === null || incomingTime > currentTime) return "advance";
if (incomingTime < currentTime) return "stale";
const incoming = Number(incomingCount);
const current = Number(currentCount);
const matching = Number(matchingCount);
return incoming === current && matching === incoming ? "replay" : "conflict";
}
async function removeMissingCurrent(client, batch, records) {
@@ -232,6 +232,26 @@ try {
);
assert.equal(firstZoneGeneration.currentUpdatedCount, 2);
assert.equal(firstZoneGeneration.currentRemovedCount, 0);
const replayedFirstZoneGeneration = await persistDataProductPublish(
pool,
zoneBatch("zones-01-stable-replay-key", firstZoneGenerationAt, [
zoneFact("zone-01", firstZoneGenerationAt),
zoneFact("zone-02", firstZoneGenerationAt),
]),
storedZoneDefinition,
);
assert.equal(replayedFirstZoneGeneration.idempotent, true);
assert.equal(replayedFirstZoneGeneration.publishedFactCount, 2);
assert.equal(replayedFirstZoneGeneration.currentUpdatedCount, 0);
assert.equal(replayedFirstZoneGeneration.currentRemovedCount, 0);
assert.equal(replayedFirstZoneGeneration.patchOperationCount, 0);
assert.equal(replayedFirstZoneGeneration.cursor, firstZoneGeneration.cursor);
await assert.rejects(
persistDataProductPublish(pool, zoneBatch("zones-01-conflicting-replay", firstZoneGenerationAt, [
zoneFact("zone-01", firstZoneGenerationAt),
]), storedZoneDefinition),
(error) => error?.status === 409 && error?.code === "data_product_generation_not_newer",
);
const secondZoneGenerationAt = "2026-07-15T10:11:00.000Z";
const secondZoneGeneration = await persistDataProductPublish(
pool,
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import { assertPublishMatchesDefinition } from "../src/data-product-delivery.mjs";
import {
assertPublishMatchesDefinition,
classifyReplacementGeneration,
} from "../src/data-product-delivery.mjs";
import { normalizeDataProductDefinition } from "../src/data-product-policy.mjs";
const definition = normalizeDataProductDefinition({
@@ -92,4 +95,42 @@ assert.doesNotThrow(() => assertPublishMatchesDefinition({
}],
}, legacyDefinition));
const acceptedGeneration = "2026-07-15T10:10:00.000Z";
assert.equal(classifyReplacementGeneration({
currentGenerationAt: null,
generationAt: acceptedGeneration,
}), "advance");
assert.equal(classifyReplacementGeneration({
currentGenerationAt: acceptedGeneration,
generationAt: "2026-07-15T10:11:00.000Z",
}), "advance");
assert.equal(classifyReplacementGeneration({
currentGenerationAt: acceptedGeneration,
generationAt: acceptedGeneration,
incomingCount: 903,
currentCount: 903,
matchingCount: 903,
}), "replay");
assert.equal(classifyReplacementGeneration({
currentGenerationAt: acceptedGeneration,
generationAt: acceptedGeneration,
incomingCount: 903,
currentCount: 903,
matchingCount: 902,
}), "conflict");
assert.equal(classifyReplacementGeneration({
currentGenerationAt: acceptedGeneration,
generationAt: acceptedGeneration,
incomingCount: 902,
currentCount: 903,
matchingCount: 902,
}), "conflict");
assert.equal(classifyReplacementGeneration({
currentGenerationAt: acceptedGeneration,
generationAt: "2026-07-15T10:09:00.000Z",
incomingCount: 903,
currentCount: 903,
matchingCount: 903,
}), "stale");
console.log("external-data-plane data product delivery policy: ok");