fix(studio): recover uncertain imports with persistent request identities
This commit is contained in:
1 parent
afea80708c
commit
c977fe95ba
12 files changed
+365
-31
No files matched your search
@@ -25,6 +25,7 @@ export interface ImportJob {
|
||||
mode?: "repair";
|
||||
syncKind?: "official" | "clone";
|
||||
operationId?: string;
|
||||
requestFingerprint?: string;
|
||||
retryOf?: string;
|
||||
id: string;
|
||||
userId: number;
|
||||
|
||||
@@ -243,11 +243,12 @@ it("attaches recovery files only to safe failed items and preserves retry histor
|
||||
).rejects.toThrow("latest retry");
|
||||
});
|
||||
|
||||
it("clears old source provenance and phase when creating a retry", async () => {
|
||||
it("clears old source provenance, request identity and phase when creating a retry", async () => {
|
||||
const s = await store();
|
||||
const original: ImportJob = {
|
||||
...job(),
|
||||
state: "completed",
|
||||
requestFingerprint: "original-request",
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
@@ -271,7 +272,49 @@ it("clears old source provenance and phase when creating a retry", async () => {
|
||||
};
|
||||
await s.create(original);
|
||||
const child = await s.retry(original.id, 4);
|
||||
expect(child?.requestFingerprint).toBeUndefined();
|
||||
expect(child?.items[0].phase).toBeUndefined();
|
||||
expect(child?.items[0].recoveredSource).toBeUndefined();
|
||||
expect(child?.items[0].sourceAttempt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects reuse of a request ID with a different immutable fingerprint", async () => {
|
||||
const s = await store();
|
||||
const original = { ...job(), requestFingerprint: "original" };
|
||||
await s.create(original);
|
||||
await expect(
|
||||
s.create({ ...original, requestFingerprint: "changed" }),
|
||||
).rejects.toThrow("Request ID already used");
|
||||
expect((await s.read(original.id)).requestFingerprint).toBe("original");
|
||||
});
|
||||
it("preserves the request identity while checkpoint data changes", async () => {
|
||||
const s = await store();
|
||||
const original = { ...job(), requestFingerprint: "original" };
|
||||
await s.create(original);
|
||||
await s.save({ ...original, state: "completed" });
|
||||
expect((await s.create(original)).state).toBe("completed");
|
||||
});
|
||||
it("does not guess request identity for legacy jobs without a fingerprint", async () => {
|
||||
const s = await store();
|
||||
const original = job();
|
||||
await s.create(original);
|
||||
await expect(
|
||||
s.create({ ...original, requestFingerprint: "new" }),
|
||||
).rejects.toThrow("Request ID already used");
|
||||
});
|
||||
|
||||
it("rejects concurrent conflicting submissions without overwriting the winner", async () => {
|
||||
const s = await store();
|
||||
const original = { ...job(), requestFingerprint: "one" };
|
||||
const results = await Promise.allSettled([
|
||||
s.create(original),
|
||||
s.create({ ...original, requestFingerprint: "two" }),
|
||||
]);
|
||||
expect(
|
||||
results.filter((result) => result.status === "fulfilled"),
|
||||
).toHaveLength(1);
|
||||
expect(results.filter((result) => result.status === "rejected")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(await s.list()).toHaveLength(1);
|
||||
});
|
||||
@@ -10,6 +10,11 @@ export const validJobId = (value: unknown): value is string =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
value,
|
||||
);
|
||||
export class ImportRequestConflict extends Error {
|
||||
constructor() {
|
||||
super("Request ID already used");
|
||||
}
|
||||
}
|
||||
export class ImportJobStore {
|
||||
constructor(readonly root = importRoot()) {}
|
||||
async save(job: ImportJob) {
|
||||
@@ -31,8 +36,11 @@ export class ImportJobStore {
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
const existing = await this.read(job.id);
|
||||
if (existing.userId !== job.userId)
|
||||
throw Error("Request ID already used");
|
||||
if (
|
||||
existing.userId !== job.userId ||
|
||||
existing.requestFingerprint !== job.requestFingerprint
|
||||
)
|
||||
throw new ImportRequestConflict();
|
||||
return existing;
|
||||
} finally {
|
||||
await fs.unlink(tmp);
|
||||
@@ -106,6 +114,7 @@ export class ImportJobStore {
|
||||
...original,
|
||||
id: retryId,
|
||||
retryOf: id,
|
||||
requestFingerprint: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
state: "queued",
|
||||
|
||||
Reference in new issue
Block a user