140 lines
5.1 KiB
TypeScript
140 lines
5.1 KiB
TypeScript
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, expect, test } from "vitest";
|
|
import { PreprocessingStateStore } from "../src/workspaces/preprocessing-state.js";
|
|
|
|
const roots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }));
|
|
});
|
|
|
|
function fixture() {
|
|
const dataRoot = mkdtempSync(join(tmpdir(), "tht-preprocessing-state-"));
|
|
roots.push(dataRoot);
|
|
return {
|
|
dataRoot,
|
|
store: new PreprocessingStateStore({ dataRoot, workspaceId: "psd-clinical" }),
|
|
};
|
|
}
|
|
|
|
test("job state creates durable 0600 JSON and enforces same-revision resume", async () => {
|
|
const { store } = fixture();
|
|
const job = await store.beginJob({
|
|
operation: "preprocess dwh",
|
|
workspaceRevision: "a".repeat(40),
|
|
descriptorBlob: "b".repeat(40),
|
|
catalogBlob: "c".repeat(40),
|
|
configDigest: "sha256:config",
|
|
bindingDigest: "sha256:bindings",
|
|
embeddingId: "ollama/qwen3-embedding:0.6b",
|
|
embeddingDimensions: 1024,
|
|
});
|
|
|
|
const path = store.jobPath(job.runId);
|
|
expect(existsSync(path)).toBe(true);
|
|
expect(statSync(path).mode & 0o777).toBe(0o600);
|
|
expect(JSON.parse(readFileSync(path, "utf8"))).toMatchObject({
|
|
schemaVersion: 2,
|
|
operation: "preprocess dwh",
|
|
workspaceId: "psd-clinical",
|
|
workspaceRevision: "a".repeat(40),
|
|
descriptorBlob: "b".repeat(40),
|
|
catalogBlob: "c".repeat(40),
|
|
configDigest: "sha256:config",
|
|
bindingDigest: "sha256:bindings",
|
|
embeddingId: "ollama/qwen3-embedding:0.6b",
|
|
embeddingDimensions: 1024,
|
|
});
|
|
|
|
await expect(store.beginJob({
|
|
operation: "preprocess dwh",
|
|
runId: job.runId,
|
|
workspaceRevision: "d".repeat(40),
|
|
descriptorBlob: "b".repeat(40),
|
|
catalogBlob: "c".repeat(40),
|
|
configDigest: "sha256:config",
|
|
bindingDigest: "sha256:bindings",
|
|
embeddingId: "ollama/qwen3-embedding:0.6b",
|
|
embeddingDimensions: 1024,
|
|
})).rejects.toMatchObject({ code: "preprocessing_resume_mismatch" });
|
|
|
|
await expect(store.beginJob({
|
|
operation: "preprocess dwh",
|
|
runId: job.runId,
|
|
workspaceRevision: "a".repeat(40),
|
|
descriptorBlob: "b".repeat(40),
|
|
catalogBlob: "c".repeat(40),
|
|
configDigest: "sha256:config",
|
|
bindingDigest: "sha256:bindings",
|
|
embeddingId: "ollama/replacement-embedding",
|
|
embeddingDimensions: 1024,
|
|
})).rejects.toMatchObject({ code: "preprocessing_resume_mismatch" });
|
|
|
|
const resumed = await store.beginJob({
|
|
operation: "preprocess dwh",
|
|
runId: job.runId,
|
|
workspaceRevision: "a".repeat(40),
|
|
descriptorBlob: "b".repeat(40),
|
|
catalogBlob: "c".repeat(40),
|
|
configDigest: "sha256:config",
|
|
bindingDigest: "sha256:bindings",
|
|
embeddingId: "ollama/qwen3-embedding:0.6b",
|
|
embeddingDimensions: 1024,
|
|
});
|
|
expect(resumed.runId).toBe(job.runId);
|
|
});
|
|
|
|
test("writer lock rejects a concurrent contender and the kernel releases it after holder death", async () => {
|
|
const f = fixture();
|
|
const other = new PreprocessingStateStore({ dataRoot: f.dataRoot, workspaceId: "psd-clinical" });
|
|
const first = await f.store.acquireWriterLock();
|
|
await expect(other.acquireWriterLock()).rejects.toMatchObject({ code: "preprocessing_conflict" });
|
|
|
|
process.kill(first.holderPid, "SIGKILL");
|
|
const deadline = Date.now() + 5_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const recovered = await other.acquireWriterLock();
|
|
await recovered.release();
|
|
return;
|
|
} catch (error) {
|
|
if ((error as { code?: string }).code !== "preprocessing_conflict") throw error;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
}
|
|
throw new Error("writer lock was not released after holder death");
|
|
});
|
|
|
|
test("session inventory guard blocks resumable sessions pinned to a different revision", async () => {
|
|
const { store } = fixture();
|
|
|
|
await expect(store.assertSessionInventoryCompatible("a".repeat(40), [
|
|
{ id: "open-other", status: "closed", archived: false, workspaceRevision: "b".repeat(40) },
|
|
])).rejects.toMatchObject({ code: "preprocessing_conflict" });
|
|
|
|
await expect(store.assertSessionInventoryCompatible("a".repeat(40), [
|
|
{ id: "current", status: "open", archived: false, workspaceRevision: "a".repeat(40) },
|
|
{ id: "finalized", status: "finalized", archived: false, workspaceRevision: "b".repeat(40) },
|
|
{ id: "archived", status: "closed", archived: true, workspaceRevision: "c".repeat(40) },
|
|
])).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("candidate and review artifacts are digest-bound durable files", async () => {
|
|
const { store } = fixture();
|
|
const runId = "1".repeat(32);
|
|
const candidate = await store.writeFkCandidates(runId, `tables: []
|
|
`);
|
|
const review = await store.writeFkReview(runId, {
|
|
reviewedCandidatesDigest: candidate.digest,
|
|
annotationsDigest: "sha256:annotations",
|
|
workspaceRevision: "a".repeat(40),
|
|
});
|
|
|
|
expect(candidate.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
expect(review.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
expect(store.readFkCandidates(runId)?.digest).toBe(candidate.digest);
|
|
expect(store.readFkReview(runId)?.reviewedCandidatesDigest).toBe(candidate.digest);
|
|
});
|