Durable atomic writer with fsync and per-chunk download progress (closes #39)
check / check (push) Successful in 41s
check / check (push) Successful in 41s
The atomic writer fsyncs the staged temp file before rename and the directory after, and is exported for reuse. downloadFile/downloadThumbnail gain an optional per-chunk onProgress hook (non-decreasing, final equals bytesWritten; no-op when absent). Retry and TAG_FINAL checks unchanged. Model: opus-4-8
This commit was merged in pull request #56.
This commit is contained in:
@@ -72,7 +72,11 @@ import { init, toBase64, STREAM_CHUNK_SIZE } from "../../src/crypto/index.js";
|
||||
import { ApiClient } from "../../src/api/client.js";
|
||||
import { ApiError, TruncatedStreamError } from "../../src/errors.js";
|
||||
import type { RetryOptions } from "../../src/retry.js";
|
||||
import { downloadFile, downloadThumbnail } from "../../src/download/index.js";
|
||||
import {
|
||||
downloadFile,
|
||||
downloadThumbnail,
|
||||
writeAtomic,
|
||||
} from "../../src/download/index.js";
|
||||
import type { EnteFile, FileMetadata } from "../../src/model/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,17 +105,48 @@ const renameHook = vi.hoisted(() => ({
|
||||
failWith: null as Error | null,
|
||||
}));
|
||||
|
||||
/**
|
||||
* `open` is wrapped so the tests can observe the durability fsyncs the atomic
|
||||
* writer performs — which are otherwise invisible: an fsync leaves no trace in
|
||||
* the file's contents. Each `FileHandle.sync()` is recorded, and rename and
|
||||
* sync events are appended to a single ordered `events` log so a test can pin
|
||||
* the sequence "fsync the temp file, rename, fsync the directory" that makes a
|
||||
* write survive a power cut. The flag the handle was opened with distinguishes
|
||||
* the temp file (`w`) from its containing directory (`r`).
|
||||
*/
|
||||
const durabilityHook = vi.hoisted(() => ({
|
||||
events: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
const { existsSync: sourceExists } = await import("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
open: async (
|
||||
path: Parameters<typeof actual.open>[0],
|
||||
flags?: Parameters<typeof actual.open>[1],
|
||||
...rest: unknown[]
|
||||
): Promise<Awaited<ReturnType<typeof actual.open>>> => {
|
||||
const handle = await actual.open(
|
||||
path,
|
||||
flags as Parameters<typeof actual.open>[1],
|
||||
...(rest as []),
|
||||
);
|
||||
const realSync = handle.sync.bind(handle);
|
||||
handle.sync = async (): Promise<void> => {
|
||||
durabilityHook.events.push(`sync:${String(flags)}:${path}`);
|
||||
await realSync();
|
||||
};
|
||||
return handle;
|
||||
},
|
||||
rename: async (from: string, to: string): Promise<void> => {
|
||||
renameHook.calls.push({
|
||||
from,
|
||||
to,
|
||||
sourceExisted: sourceExists(from),
|
||||
});
|
||||
durabilityHook.events.push(`rename:${to}`);
|
||||
if (renameHook.failWith !== null) {
|
||||
throw renameHook.failWith;
|
||||
}
|
||||
@@ -123,6 +158,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
beforeEach(() => {
|
||||
renameHook.calls.length = 0;
|
||||
renameHook.failWith = null;
|
||||
durabilityHook.events.length = 0;
|
||||
});
|
||||
|
||||
let testDir: string;
|
||||
@@ -1061,3 +1097,102 @@ describe("download retries: corruption is not retried", () => {
|
||||
expect(requests()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Durable atomic writes
|
||||
//
|
||||
// `writeAtomic` is exported so the metadata store can reuse the same
|
||||
// power-cut-safe write. Its durability is the point: the bytes and the new
|
||||
// directory entry must both be on stable storage before it returns, so a crash
|
||||
// immediately afterwards cannot resurrect an empty renamed file (#22 area 1).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("writeAtomic", () => {
|
||||
it("fsyncs the temp file before the rename and the directory after", async () => {
|
||||
const dir = mkdtempSync(join(testDir, "atomic-"));
|
||||
const dest = join(dir, "durable.bin");
|
||||
const bytes = patternBytes(2048, 71);
|
||||
|
||||
await writeAtomic(dest, bytes);
|
||||
|
||||
expect(readFileSync(dest)).toEqual(Buffer.from(bytes));
|
||||
// The order is the durability contract: fsync the staged temp file so
|
||||
// its contents are on disk, rename it into place, then fsync the
|
||||
// directory so that new entry is on disk too. Do the directory fsync
|
||||
// before the rename, or skip it, and a crash can lose the rename.
|
||||
expect(durabilityHook.events).toHaveLength(3);
|
||||
expect(durabilityHook.events[0]).toMatch(/^sync:w:.*\.tmp$/);
|
||||
expect(durabilityHook.events[1]).toBe(`rename:${dest}`);
|
||||
expect(durabilityHook.events[2]).toBe(`sync:r:${dir}`);
|
||||
});
|
||||
|
||||
it("leaves no temp file behind when the write cannot be renamed", async () => {
|
||||
const dir = mkdtempSync(join(testDir, "atomic-fail-"));
|
||||
const dest = join(dir, "unrenamable.bin");
|
||||
renameHook.failWith = new Error("simulated rename failure");
|
||||
|
||||
await expect(writeAtomic(dest, patternBytes(64, 72))).rejects.toThrow(
|
||||
"simulated rename failure",
|
||||
);
|
||||
|
||||
// The staged temp file was fsynced, then the rename failed; the cleanup
|
||||
// path must remove it so a repeatedly failing write cannot fill the disk.
|
||||
expect(existsSync(dest)).toBe(false);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-chunk progress
|
||||
//
|
||||
// Callers streaming a large file want bytes-written as it lands, not only the
|
||||
// final total. The hook fires as decrypted plaintext accumulates; its values
|
||||
// are non-decreasing and its last value is exactly `bytesWritten`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.each(entryPoints)("$name progress", ({ name, download }) => {
|
||||
it("reports monotonic progress ending at bytesWritten", async () => {
|
||||
// The multi-chunk fixture pulls one full 4 MiB chunk and then a small
|
||||
// final chunk, so the callback fires more than once and monotonicity is
|
||||
// actually observable rather than trivially true for a single fire.
|
||||
const { api, file } = fixtureFor(
|
||||
multiChunkKey,
|
||||
multiChunk.header,
|
||||
multiChunk.body,
|
||||
);
|
||||
const outPath = join(
|
||||
mkdtempSync(join(testDir, `${name}-progress-`)),
|
||||
"p.bin",
|
||||
);
|
||||
const seen: number[] = [];
|
||||
|
||||
const result = await download(api, file, outPath, (bytesDone) => {
|
||||
seen.push(bytesDone);
|
||||
});
|
||||
|
||||
expect(seen.length).toBeGreaterThan(1);
|
||||
for (let i = 1; i < seen.length; i++) {
|
||||
expect(seen[i]!).toBeGreaterThan(seen[i - 1]!);
|
||||
}
|
||||
expect(seen[seen.length - 1]).toBe(result.bytesWritten);
|
||||
expect(result.bytesWritten).toBe(multiChunk.plaintext.length);
|
||||
});
|
||||
|
||||
it("downloads normally when no progress callback is given", async () => {
|
||||
// The callback is optional and its absence must be side-effect-free:
|
||||
// the download succeeds exactly as it does elsewhere in this file.
|
||||
const plaintext = patternBytes(300, 73);
|
||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const { header, ciphertext } = encryptFileBody(plaintext, key);
|
||||
const { api, file } = fixtureFor(key, header, ciphertext);
|
||||
const outPath = join(
|
||||
mkdtempSync(join(testDir, `${name}-noprog-`)),
|
||||
"n.bin",
|
||||
);
|
||||
|
||||
const result = await download(api, file, outPath);
|
||||
|
||||
expect(result.bytesWritten).toBe(plaintext.length);
|
||||
expectSameBytes(readFileSync(outPath), plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user