feat(download): durable atomic writer and per-chunk progress hook (closes #39)
check / check (push) Successful in 26s
check / check (push) Successful in 26s
Add fsync to the atomic writer and export it, and give the download functions an optional per-chunk progress hook. writeAtomic now fsyncs the staged temp file before the rename and fsyncs the containing directory after it, so a power cut immediately after the write cannot resurrect an empty renamed file. It is exported so the metadata store can reuse the same durable write. downloadFile and downloadThumbnail take an optional onProgress(bytesDone) callback that fires within streamDecrypt as decrypted plaintext accumulates; its values are non-decreasing and the last equals bytesWritten. Absent, the callback is a no-op. Whole-file buffering, retry semantics, and the TAG_FINAL truncation checks are unchanged. Covers the fsync durability gap of #22 (area 1) only; orphan reaping, symlink/mode docs, and the remaining test coverage there stay for a later unit, so #22 is referenced, not closed. Model: opus-4-8
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