fix(data-plane): accept exact replacement replays
This commit is contained in:
parent
45884cd4dc
commit
a9b8d71968
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(scriptDir, "../deploy-artifacts"));
|
||||||
|
const [patchId = "external-data-plane-replacement-replay-v1-20260721-014", ...extra] = process.argv.slice(2);
|
||||||
|
const sourceRelative = "services/external-data-plane/src/data-product-delivery.mjs";
|
||||||
|
const destinationRelative = `platform/${sourceRelative}`;
|
||||||
|
|
||||||
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("usage: build-external-data-plane-replacement-replay-artifact.mjs [patch-id]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = resolve(platformRoot, sourceRelative);
|
||||||
|
const sourceText = await readFile(source, "utf8");
|
||||||
|
for (const marker of [
|
||||||
|
'replacement.disposition === "replay"',
|
||||||
|
"classifyReplacementGeneration",
|
||||||
|
'throw deliveryError("data_product_generation_not_newer", 409)',
|
||||||
|
'join current_scope using (source_id, semantic_type, fingerprint)',
|
||||||
|
]) {
|
||||||
|
if (!sourceText.includes(marker)) throw new Error(`replacement_replay_boundary_missing:${marker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-edp-replacement-replay-"));
|
||||||
|
const payloadPath = join(stage, "payload", destinationRelative);
|
||||||
|
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(payloadPath), { recursive: true });
|
||||||
|
await cp(source, payloadPath, { force: false });
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${destinationRelative}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
patchId,
|
||||||
|
artifact: target,
|
||||||
|
sha256,
|
||||||
|
entries: [destinationRelative],
|
||||||
|
service: "external-data-plane",
|
||||||
|
database: "preserved",
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip, io, pathlib, sys, tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1], 'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
BUILDER = SCRIPT_DIR / "build-external-data-plane-replacement-replay-artifact.mjs"
|
||||||
|
PATCH_ID = "external-data-plane-replacement-replay-v1-20260721-014"
|
||||||
|
ENTRY = "platform/services/external-data-plane/src/data-product-delivery.mjs"
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalDataPlaneReplacementReplayArtifactTest(unittest.TestCase):
|
||||||
|
def build(self, artifact_dir):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", str(BUILDER), PATCH_ID],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
def test_exact_runtime_slice_is_deterministic(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-edp-replay-artifact-") as directory:
|
||||||
|
artifact_dir = Path(directory)
|
||||||
|
first = self.build(artifact_dir)
|
||||||
|
first_bytes = Path(first["artifact"]).read_bytes()
|
||||||
|
second = self.build(artifact_dir)
|
||||||
|
second_bytes = Path(second["artifact"]).read_bytes()
|
||||||
|
|
||||||
|
self.assertEqual(first["entries"], [ENTRY])
|
||||||
|
self.assertEqual(first["service"], "external-data-plane")
|
||||||
|
self.assertEqual(first["database"], "preserved")
|
||||||
|
self.assertEqual(first["sha256"], hashlib.sha256(first_bytes).hexdigest())
|
||||||
|
self.assertEqual(first["sha256"], second["sha256"])
|
||||||
|
self.assertEqual(first_bytes, second_bytes)
|
||||||
|
|
||||||
|
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||||
|
names = archive.getnames()
|
||||||
|
manifest = archive.extractfile("manifest.env").read().decode("utf-8")
|
||||||
|
files = archive.extractfile("files.txt").read().decode("utf-8")
|
||||||
|
payload = archive.extractfile(f"payload/{ENTRY}").read()
|
||||||
|
|
||||||
|
self.assertEqual(manifest, f"id={PATCH_ID}\ncomponent=platform\ntype=app-overlay\n")
|
||||||
|
self.assertEqual(files, f"{ENTRY}\n")
|
||||||
|
self.assertIn(f"payload/{ENTRY}", names)
|
||||||
|
self.assertEqual(
|
||||||
|
payload,
|
||||||
|
(PLATFORM_ROOT / "services/external-data-plane/src/data-product-delivery.mjs").read_bytes(),
|
||||||
|
)
|
||||||
|
self.assertFalse(any("/test/" in name or "/node_modules/" in name for name in names))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -104,8 +104,6 @@ export async function persistDataProductPublish(pool, batch, definition, {
|
||||||
return { ...normalizeReceipt(existing.rows[0]), idempotent: true };
|
return { ...normalizeReceipt(existing.rows[0]), idempotent: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batch.batch.mode === "replace") await lockReplacementGeneration(client, batch);
|
|
||||||
|
|
||||||
const records = batch.facts.map((fact) => ({
|
const records = batch.facts.map((fact) => ({
|
||||||
source_id: fact.sourceId,
|
source_id: fact.sourceId,
|
||||||
semantic_type: fact.semanticType,
|
semantic_type: fact.semanticType,
|
||||||
|
|
@ -116,6 +114,33 @@ export async function persistDataProductPublish(pool, batch, definition, {
|
||||||
fingerprint: fingerprint(fact),
|
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(
|
const updated = await client.query(
|
||||||
`insert into external_data_plane_current (
|
`insert into external_data_plane_current (
|
||||||
tenant_id, connection_id, provider_id, data_product_id, source_id, semantic_type,
|
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;
|
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];
|
const scope = [batch.source.tenantId, batch.source.connectionId, batch.source.providerId, batch.contract.dataProductId];
|
||||||
await client.query(
|
await client.query(
|
||||||
`insert into external_data_plane_delivery_state (
|
`insert into external_data_plane_delivery_state (
|
||||||
|
|
@ -468,9 +493,59 @@ async function lockReplacementGeneration(client, batch) {
|
||||||
const currentGenerationAt = state.rows[0]?.currentGenerationAt
|
const currentGenerationAt = state.rows[0]?.currentGenerationAt
|
||||||
? new Date(state.rows[0].currentGenerationAt)
|
? new Date(state.rows[0].currentGenerationAt)
|
||||||
: null;
|
: null;
|
||||||
if (currentGenerationAt && generationAt <= currentGenerationAt) {
|
if (!currentGenerationAt || generationAt > currentGenerationAt) {
|
||||||
|
return { disposition: "advance" };
|
||||||
|
}
|
||||||
|
if (generationAt < currentGenerationAt) {
|
||||||
throw deliveryError("data_product_generation_not_newer", 409);
|
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) {
|
async function removeMissingCurrent(client, batch, records) {
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,26 @@ try {
|
||||||
);
|
);
|
||||||
assert.equal(firstZoneGeneration.currentUpdatedCount, 2);
|
assert.equal(firstZoneGeneration.currentUpdatedCount, 2);
|
||||||
assert.equal(firstZoneGeneration.currentRemovedCount, 0);
|
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 secondZoneGenerationAt = "2026-07-15T10:11:00.000Z";
|
||||||
const secondZoneGeneration = await persistDataProductPublish(
|
const secondZoneGeneration = await persistDataProductPublish(
|
||||||
pool,
|
pool,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import assert from "node:assert/strict";
|
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";
|
import { normalizeDataProductDefinition } from "../src/data-product-policy.mjs";
|
||||||
|
|
||||||
const definition = normalizeDataProductDefinition({
|
const definition = normalizeDataProductDefinition({
|
||||||
|
|
@ -92,4 +95,42 @@ assert.doesNotThrow(() => assertPublishMatchesDefinition({
|
||||||
}],
|
}],
|
||||||
}, legacyDefinition));
|
}, 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");
|
console.log("external-data-plane data product delivery policy: ok");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue