Retry transient Windows state-file replaces
This commit is contained in:
parent
526a47e06a
commit
3eef1f1f1b
3 changed files with 106 additions and 4 deletions
|
|
@ -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"}
|
||||
49
dist/single-file-messagepack-backing-store.js
vendored
49
dist/single-file-messagepack-backing-store.js
vendored
|
|
@ -18,6 +18,7 @@ const legacyEnvelopeArraySchema = zod_1.z.array(zod_1.z.object({
|
|||
payload: zod_1.z.unknown(),
|
||||
storedAt: zod_1.z.string().min(1),
|
||||
}));
|
||||
const transientReplaceErrorCodes = new Set(["EBUSY", "EPERM", "EACCES"]);
|
||||
class SingleFileMessagePackBackingStore {
|
||||
filePath;
|
||||
#writeQueue = Promise.resolve();
|
||||
|
|
@ -101,7 +102,7 @@ class SingleFileMessagePackBackingStore {
|
|||
.slice(2)}`;
|
||||
try {
|
||||
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) {
|
||||
await (0, promises_1.rm)(tempPath, { force: true }).catch(() => undefined);
|
||||
|
|
@ -110,6 +111,49 @@ class 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) {
|
||||
if (payload instanceof Uint8Array) {
|
||||
return payload;
|
||||
|
|
@ -128,3 +172,6 @@ function normalizePayload(payload) {
|
|||
function isObject(value) {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
function isErrnoException(error) {
|
||||
return typeof error === "object" && error !== null && "code" in error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { decode, encode } from "@msgpack/msgpack";
|
||||
|
|
@ -22,6 +22,7 @@ const legacyEnvelopeArraySchema = z.array(
|
|||
storedAt: z.string().min(1),
|
||||
}),
|
||||
);
|
||||
const transientReplaceErrorCodes = new Set(["EBUSY", "EPERM", "EACCES"]);
|
||||
|
||||
export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
||||
readonly filePath: string;
|
||||
|
|
@ -129,7 +130,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
|
||||
try {
|
||||
await writeFile(tempPath, encode(entries));
|
||||
await rename(tempPath, this.filePath);
|
||||
await replaceFileWithRetry(tempPath, this.filePath);
|
||||
} catch (error) {
|
||||
await rm(tempPath, { force: true }).catch(() => undefined);
|
||||
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 {
|
||||
if (payload instanceof Uint8Array) {
|
||||
return payload;
|
||||
|
|
@ -161,3 +212,7 @@ function normalizePayload(payload: unknown): Uint8Array {
|
|||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
return typeof error === "object" && error !== null && "code" in error;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue