Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
3eef1f1f1b Retry transient Windows state-file replaces 2026-05-24 13:08:46 +01:00
3 changed files with 106 additions and 4 deletions

View file

@ -1 +1 @@
{"version":3,"file":"single-file-messagepack-backing-store.d.ts","sourceRoot":"","sources":["../src/single-file-messagepack-backing-store.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAmBpF,qBAAa,iCAAkC,YAAW,iBAAiB;;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAId,QAAQ,EAAE,MAAM;IAItB,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAiCvC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,OAAO,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAiDzF"} {"version":3,"file":"single-file-messagepack-backing-store.d.ts","sourceRoot":"","sources":["../src/single-file-messagepack-backing-store.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAoBpF,qBAAa,iCAAkC,YAAW,iBAAiB;;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAId,QAAQ,EAAE,MAAM;IAItB,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAiCvC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,OAAO,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAiDzF"}

View file

@ -18,6 +18,7 @@ const legacyEnvelopeArraySchema = zod_1.z.array(zod_1.z.object({
payload: zod_1.z.unknown(), payload: zod_1.z.unknown(),
storedAt: zod_1.z.string().min(1), storedAt: zod_1.z.string().min(1),
})); }));
const transientReplaceErrorCodes = new Set(["EBUSY", "EPERM", "EACCES"]);
class SingleFileMessagePackBackingStore { class SingleFileMessagePackBackingStore {
filePath; filePath;
#writeQueue = Promise.resolve(); #writeQueue = Promise.resolve();
@ -101,7 +102,7 @@ class SingleFileMessagePackBackingStore {
.slice(2)}`; .slice(2)}`;
try { try {
await (0, promises_1.writeFile)(tempPath, (0, msgpack_1.encode)(entries)); await (0, promises_1.writeFile)(tempPath, (0, msgpack_1.encode)(entries));
await (0, promises_1.rename)(tempPath, this.filePath); await replaceFileWithRetry(tempPath, this.filePath);
} }
catch (error) { catch (error) {
await (0, promises_1.rm)(tempPath, { force: true }).catch(() => undefined); await (0, promises_1.rm)(tempPath, { force: true }).catch(() => undefined);
@ -110,6 +111,49 @@ class SingleFileMessagePackBackingStore {
} }
} }
exports.SingleFileMessagePackBackingStore = SingleFileMessagePackBackingStore; exports.SingleFileMessagePackBackingStore = SingleFileMessagePackBackingStore;
async function replaceFileWithRetry(sourcePath, targetPath) {
const delays = [25, 75, 150, 300, 600];
let lastError;
for (let attempt = 0; attempt <= delays.length; attempt += 1) {
try {
await (0, promises_1.rename)(sourcePath, targetPath);
return;
}
catch (error) {
lastError = error;
if (!isTransientReplaceError(error) || attempt === delays.length) {
break;
}
await delay(delays[attempt] ?? 0);
}
}
if (isTransientReplaceError(lastError)) {
try {
await (0, promises_1.copyFile)(sourcePath, targetPath);
await (0, promises_1.rm)(sourcePath, { force: true });
return;
}
catch (copyError) {
throw annotateReplaceError(copyError, sourcePath, targetPath);
}
}
throw annotateReplaceError(lastError, sourcePath, targetPath);
}
function isTransientReplaceError(error) {
return isErrnoException(error) &&
typeof error.code === "string" &&
transientReplaceErrorCodes.has(error.code);
}
function annotateReplaceError(error, sourcePath, targetPath) {
if (error instanceof Error) {
error.message = `CultCache failed to replace ${targetPath} from ${sourcePath}: ${error.message}`;
return error;
}
return new Error(`CultCache failed to replace ${targetPath} from ${sourcePath}: ${String(error)}`);
}
function delay(milliseconds) {
return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
}
function normalizePayload(payload) { function normalizePayload(payload) {
if (payload instanceof Uint8Array) { if (payload instanceof Uint8Array) {
return payload; return payload;
@ -128,3 +172,6 @@ function normalizePayload(payload) {
function isObject(value) { function isObject(value) {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
function isErrnoException(error) {
return typeof error === "object" && error !== null && "code" in error;
}

View file

@ -1,4 +1,4 @@
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { decode, encode } from "@msgpack/msgpack"; import { decode, encode } from "@msgpack/msgpack";
@ -22,6 +22,7 @@ const legacyEnvelopeArraySchema = z.array(
storedAt: z.string().min(1), storedAt: z.string().min(1),
}), }),
); );
const transientReplaceErrorCodes = new Set(["EBUSY", "EPERM", "EACCES"]);
export class SingleFileMessagePackBackingStore implements CacheBackingStore { export class SingleFileMessagePackBackingStore implements CacheBackingStore {
readonly filePath: string; readonly filePath: string;
@ -129,7 +130,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
try { try {
await writeFile(tempPath, encode(entries)); await writeFile(tempPath, encode(entries));
await rename(tempPath, this.filePath); await replaceFileWithRetry(tempPath, this.filePath);
} catch (error) { } catch (error) {
await rm(tempPath, { force: true }).catch(() => undefined); await rm(tempPath, { force: true }).catch(() => undefined);
throw error; throw error;
@ -137,6 +138,56 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
} }
} }
async function replaceFileWithRetry(sourcePath: string, targetPath: string): Promise<void> {
const delays = [25, 75, 150, 300, 600];
let lastError: unknown;
for (let attempt = 0; attempt <= delays.length; attempt += 1) {
try {
await rename(sourcePath, targetPath);
return;
} catch (error) {
lastError = error;
if (!isTransientReplaceError(error) || attempt === delays.length) {
break;
}
await delay(delays[attempt] ?? 0);
}
}
if (isTransientReplaceError(lastError)) {
try {
await copyFile(sourcePath, targetPath);
await rm(sourcePath, { force: true });
return;
} catch (copyError) {
throw annotateReplaceError(copyError, sourcePath, targetPath);
}
}
throw annotateReplaceError(lastError, sourcePath, targetPath);
}
function isTransientReplaceError(error: unknown): boolean {
return isErrnoException(error) &&
typeof error.code === "string" &&
transientReplaceErrorCodes.has(error.code);
}
function annotateReplaceError(error: unknown, sourcePath: string, targetPath: string): Error {
if (error instanceof Error) {
error.message = `CultCache failed to replace ${targetPath} from ${sourcePath}: ${error.message}`;
return error;
}
return new Error(`CultCache failed to replace ${targetPath} from ${sourcePath}: ${String(error)}`);
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
}
function normalizePayload(payload: unknown): Uint8Array { function normalizePayload(payload: unknown): Uint8Array {
if (payload instanceof Uint8Array) { if (payload instanceof Uint8Array) {
return payload; return payload;
@ -161,3 +212,7 @@ function normalizePayload(payload: unknown): Uint8Array {
function isObject(value: unknown): value is Record<string, unknown> { function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return typeof error === "object" && error !== null && "code" in error;
}