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:
+51
-10
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { rename, rm, writeFile } from "node:fs/promises";
|
||||
import { open, rename, rm } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
fromBase64,
|
||||
@@ -19,12 +19,20 @@ export interface DownloadResult {
|
||||
bytesWritten: number;
|
||||
}
|
||||
|
||||
// Fired as decrypted plaintext accumulates, with the running total of
|
||||
// plaintext bytes recovered so far. Within one download it is non-decreasing
|
||||
// and its last value equals the final `bytesWritten`. A retry restarts the
|
||||
// file from byte zero (see `fetchAndDecrypt`), so a fresh attempt begins its
|
||||
// own count from zero.
|
||||
export type ProgressCallback = (bytesDone: number) => void;
|
||||
|
||||
const ENC_CHUNK_SIZE = STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD;
|
||||
|
||||
const streamDecrypt = async (
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
header: Uint8Array,
|
||||
key: Uint8Array,
|
||||
onProgress?: ProgressCallback,
|
||||
): Promise<Uint8Array> => {
|
||||
const state = initStreamPull(header, key);
|
||||
const reader = stream.getReader();
|
||||
@@ -51,6 +59,7 @@ const streamDecrypt = async (
|
||||
totalPlain += plaintext.length;
|
||||
chunksPulled++;
|
||||
lastTag = tag;
|
||||
onProgress?.(totalPlain);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
@@ -76,6 +85,7 @@ const streamDecrypt = async (
|
||||
totalPlain += pulled.plaintext.length;
|
||||
chunksPulled++;
|
||||
lastTag = pulled.tag;
|
||||
onProgress?.(totalPlain);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -107,21 +117,47 @@ const streamDecrypt = async (
|
||||
return result;
|
||||
};
|
||||
|
||||
// Write `plaintext` to `destination` atomically: stage it in a temporary
|
||||
// sibling file (same directory, so the rename cannot cross a filesystem
|
||||
// boundary) and rename it into place. Callers therefore never observe a
|
||||
// partially written destination, and a pre-existing file at that path is
|
||||
// replaced only once the new contents are complete on disk.
|
||||
const writeAtomic = async (
|
||||
// Write `plaintext` to `destination` atomically and durably: stage it in a
|
||||
// temporary sibling file (same directory, so the rename cannot cross a
|
||||
// filesystem boundary) and rename it into place. Callers therefore never
|
||||
// observe a partially written destination, and a pre-existing file at that
|
||||
// path is replaced only once the new contents are complete on disk.
|
||||
//
|
||||
// Durability against a power cut needs two fsyncs. Without them the write can
|
||||
// return while the data or the rename is still only in the kernel's page
|
||||
// cache, and a crash then resurrects an empty renamed file — exactly the
|
||||
// corruption a later backup run treats as a complete download. So the temp
|
||||
// file's contents are fsynced before the rename, and the containing directory
|
||||
// is fsynced after it, so both the bytes and the new directory entry are on
|
||||
// stable storage before this returns.
|
||||
//
|
||||
// Exported so the metadata store can reuse the same durable write.
|
||||
export const writeAtomic = async (
|
||||
destination: string,
|
||||
plaintext: Uint8Array,
|
||||
): Promise<void> => {
|
||||
const dir = dirname(destination);
|
||||
// The random suffix keeps concurrent downloads of the same destination
|
||||
// from stepping on each other's temporary file.
|
||||
const tmpPath = join(dirname(destination), `.quak-${randomUUID()}.tmp`);
|
||||
const tmpPath = join(dir, `.quak-${randomUUID()}.tmp`);
|
||||
try {
|
||||
await writeFile(tmpPath, plaintext);
|
||||
const handle = await open(tmpPath, "w");
|
||||
try {
|
||||
await handle.writeFile(plaintext);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(tmpPath, destination);
|
||||
// Fsync the directory so the rename itself survives a crash: renaming
|
||||
// over a synced temp file still leaves the new directory entry in the
|
||||
// page cache until the directory is synced.
|
||||
const dirHandle = await open(dir, "r");
|
||||
try {
|
||||
await dirHandle.sync();
|
||||
} finally {
|
||||
await dirHandle.close();
|
||||
}
|
||||
} catch (err) {
|
||||
// Best-effort cleanup. A failure to remove the temporary file must
|
||||
// never replace the error that actually explains what went wrong.
|
||||
@@ -150,16 +186,18 @@ const fetchAndDecrypt = async (
|
||||
openStream: () => Promise<ReadableStream<Uint8Array>>,
|
||||
header: Uint8Array,
|
||||
key: Uint8Array,
|
||||
onProgress?: ProgressCallback,
|
||||
): Promise<Uint8Array> =>
|
||||
withRetry(async () => {
|
||||
const stream = await openStream();
|
||||
return streamDecrypt(stream, header, key);
|
||||
return streamDecrypt(stream, header, key, onProgress);
|
||||
}, api.getRetryOptions());
|
||||
|
||||
export const downloadFile = async (
|
||||
api: ApiClient,
|
||||
file: EnteFile,
|
||||
outPath?: string,
|
||||
onProgress?: ProgressCallback,
|
||||
): Promise<DownloadResult> => {
|
||||
const resolvedPath = outPath ?? file.metadata.title;
|
||||
const header = fromBase64(file.file.decryptionHeader);
|
||||
@@ -168,6 +206,7 @@ export const downloadFile = async (
|
||||
() => api.getFileStream(file.id, { retry: false }),
|
||||
header,
|
||||
file.key,
|
||||
onProgress,
|
||||
);
|
||||
// Outside the retry, deliberately: only the attempt that produced a
|
||||
// complete, authenticated plaintext gets to stage a temporary file, so a
|
||||
@@ -181,6 +220,7 @@ export const downloadThumbnail = async (
|
||||
api: ApiClient,
|
||||
file: EnteFile,
|
||||
outPath?: string,
|
||||
onProgress?: ProgressCallback,
|
||||
): Promise<DownloadResult> => {
|
||||
const resolvedPath = outPath ?? `thumb_${file.metadata.title}`;
|
||||
const header = fromBase64(file.thumbnail.decryptionHeader);
|
||||
@@ -189,6 +229,7 @@ export const downloadThumbnail = async (
|
||||
() => api.getThumbnailStream(file.id, { retry: false }),
|
||||
header,
|
||||
file.key,
|
||||
onProgress,
|
||||
);
|
||||
await writeAtomic(resolvedPath, plaintext);
|
||||
return { path: resolvedPath, bytesWritten: plaintext.length };
|
||||
|
||||
Reference in New Issue
Block a user