feat: harden self-managed reconciliation
This commit is contained in:
@@ -13,7 +13,11 @@ import {
|
||||
|
||||
const workspace = `sha256:${"a".repeat(64)}` as Sha256Digest;
|
||||
|
||||
function build(id: string, imageKey = "project\0service\0image"): BuildRecord {
|
||||
function build(
|
||||
id: string,
|
||||
imageKey = "project\0service\0image",
|
||||
createdAt = "2026-09-02T00:00:00.000Z",
|
||||
): BuildRecord {
|
||||
const request: BuildRequest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
id,
|
||||
@@ -31,7 +35,7 @@ function build(id: string, imageKey = "project\0service\0image"): BuildRecord {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
id,
|
||||
state: "queued" as const,
|
||||
createdAt: "2026-09-02T00:00:00.000Z",
|
||||
createdAt,
|
||||
};
|
||||
return {
|
||||
apiVersion: BUILD_RECORD_API_VERSION,
|
||||
@@ -61,25 +65,28 @@ describe("build store", () => {
|
||||
await expect(
|
||||
store.createBuild(build("one", "different-key")),
|
||||
).rejects.toBeInstanceOf(BuildStoreConflictError);
|
||||
await expect(store.createBuild(build("two"))).rejects.toBeInstanceOf(
|
||||
BuildStoreConflictError,
|
||||
);
|
||||
const replacement = await store.createBuild(build("two"));
|
||||
expect(replacement.created).toBe(true);
|
||||
expect(replacement.superseded?.[0]?.metadata.name).toBe("one");
|
||||
|
||||
const first = (await store.getBuild("one"))!;
|
||||
const digest = `sha256:${"b".repeat(64)}` as Sha256Digest;
|
||||
first.metadata.resourceVersion = "2";
|
||||
first.metadata.resourceVersion = "3";
|
||||
Object.assign(first.status, {
|
||||
state: "succeeded",
|
||||
digest,
|
||||
finishedAt: first.status.createdAt,
|
||||
});
|
||||
await store.replaceBuild(first, "1");
|
||||
expect((await store.createBuild(build("two"))).created).toBe(true);
|
||||
await store.replaceBuild(first, "2");
|
||||
expect(
|
||||
(await store.createBuild(build("three", "2026-09-02T00:00:02.000Z")))
|
||||
.created,
|
||||
).toBe(true);
|
||||
|
||||
const changed = (await store.getBuild("one"))!;
|
||||
changed.metadata.resourceVersion = "3";
|
||||
changed.status.digest = `sha256:${"c".repeat(64)}`;
|
||||
await expect(store.replaceBuild(changed, "2")).rejects.toThrow("immutable");
|
||||
await expect(store.replaceBuild(changed, "3")).rejects.toThrow("immutable");
|
||||
});
|
||||
|
||||
test("optimistically updates resumable upload records without leaking mutable data", async () => {
|
||||
@@ -107,4 +114,117 @@ describe("build store", () => {
|
||||
BuildStoreConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test("supersedes only the current image and leaves terminal history", async () => {
|
||||
const store = new MemoryBuildStore();
|
||||
await store.createBuild(
|
||||
build("old", "project\0service\0image", "2026-09-02T00:00:00.000Z"),
|
||||
);
|
||||
const result = await store.createBuild(
|
||||
build("new", "project\0service\0image", "2026-09-02T00:00:01.000Z"),
|
||||
);
|
||||
expect(result.superseded?.[0]?.status.error).toBe(
|
||||
"Superseded by newer build",
|
||||
);
|
||||
expect((await store.getBuild("old"))?.status.state).toBe("failed");
|
||||
expect((await store.createBuild(build("new"))).created).toBe(false);
|
||||
expect(
|
||||
(await store.createBuild(build("other", "project\0service\0other")))
|
||||
.created,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("concurrent same-image submissions have one active record", async () => {
|
||||
const store = new MemoryBuildStore();
|
||||
const results = await Promise.all([
|
||||
store.createBuild(build("first")),
|
||||
store.createBuild(build("second")),
|
||||
store.createBuild(build("third")),
|
||||
]);
|
||||
expect(results.filter((result) => result.created)).toHaveLength(3);
|
||||
const active = (await store.listBuilds()).filter(
|
||||
(record) =>
|
||||
record.status.state !== "succeeded" && record.status.state !== "failed",
|
||||
);
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0]?.metadata.name).toBe("third");
|
||||
});
|
||||
|
||||
test("recovers an active record whose in-memory lock was lost", async () => {
|
||||
const store = new MemoryBuildStore();
|
||||
await store.createBuild(build("orphan"));
|
||||
(store as unknown as { active: Map<string, string> }).active.clear();
|
||||
|
||||
const replacement = await store.createBuild(build("replacement"));
|
||||
expect(replacement.superseded?.[0]?.metadata.name).toBe("orphan");
|
||||
expect((await store.getBuild("orphan"))?.status.state).toBe("failed");
|
||||
expect(
|
||||
await store.ownsBuild("project\0service\0image", "replacement"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("a delayed older retry cannot reclaim a newer same-image lock", async () => {
|
||||
const store = new MemoryBuildStore();
|
||||
await store.createBuild(
|
||||
build("a", "project\0service\0image", "2026-09-02T00:00:00.000Z"),
|
||||
);
|
||||
(store as unknown as { active: Map<string, string> }).active.clear();
|
||||
await store.createBuild(
|
||||
build("b", "project\0service\0image", "2026-09-02T00:00:01.000Z"),
|
||||
);
|
||||
|
||||
const delayed = await store.createBuild(
|
||||
build("a", "project\0service\0image", "2026-09-02T00:00:00.000Z"),
|
||||
);
|
||||
expect(delayed.created).toBe(false);
|
||||
expect(delayed.record.status.error).toBe("Superseded by newer build");
|
||||
expect(await store.ownsBuild("project\0service\0image", "b")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns cleanup metadata when a delayed candidate loses to a newer record", async () => {
|
||||
const store = new MemoryBuildStore();
|
||||
await store.createBuild(
|
||||
build("newer", "project\0service\0image", "2026-09-02T00:00:01.000Z"),
|
||||
);
|
||||
|
||||
const result = await store.createBuild(
|
||||
build("delayed", "project\0service\0image", "2026-09-02T00:00:00.000Z"),
|
||||
);
|
||||
|
||||
expect(result.created).toBe(false);
|
||||
expect(result.superseded?.map((record) => record.metadata.name)).toEqual([
|
||||
"delayed",
|
||||
]);
|
||||
expect(
|
||||
(await store.createBuild(build("delayed"))).superseded,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("orders and reconciles legacy records with invalid metadata timestamps", async () => {
|
||||
const store = new MemoryBuildStore();
|
||||
const legacy = build(
|
||||
"legacy",
|
||||
"project\0service\0image",
|
||||
"2026-09-02T00:00:00.000Z",
|
||||
);
|
||||
legacy.metadata.creationTimestamp = undefined as unknown as string;
|
||||
await store.createBuild(legacy);
|
||||
|
||||
const replacement = await store.createBuild(
|
||||
build(
|
||||
"replacement",
|
||||
"project\0service\0image",
|
||||
"2026-09-02T00:00:01.000Z",
|
||||
),
|
||||
);
|
||||
expect(replacement.created).toBe(true);
|
||||
expect((await store.getBuild("legacy"))?.status.state).toBe("failed");
|
||||
|
||||
const malformed = build("a", "project\0service\0other");
|
||||
malformed.metadata.creationTimestamp = 0 as unknown as string;
|
||||
await store.createBuild(malformed);
|
||||
expect(
|
||||
(await store.listBuilds()).map((record) => record.metadata.name),
|
||||
).toEqual(["a", "legacy", "replacement"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user