Keep another process's downloads when opening a library (closes #105)
check / check (push) Successful in 1m18s
check / check (push) Successful in 1m18s
The download writer's temp files are now named .quak-<pid>-<random>.tmp. removeLeftoverTempFiles moves from the backup into the download module and deletes a .quak-*.tmp file only when the process ID in its name is no longer running; the content cache calls it at open() instead of deleting every temp file, so a download in progress in another process sharing the cache survives. The README backup layout and TODO.md are updated. Model: opus-5-5
This commit was merged in pull request #120.
This commit is contained in:
+1
-31
@@ -45,7 +45,7 @@ import {
|
||||
import { copyFile, rename, rm } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, relative } from "node:path";
|
||||
|
||||
import { fsyncPath } from "./download/index.js";
|
||||
import { fsyncPath, removeLeftoverTempFiles } from "./download/index.js";
|
||||
import { safeExtension, sanitizeFileName } from "./filename.js";
|
||||
import type { Collection, EnteFile } from "./model/types.js";
|
||||
|
||||
@@ -184,36 +184,6 @@ const copyAtomic = async (src: string, dest: string): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
// A process-ID check: signal 0 delivers nothing and only reports whether the
|
||||
// process exists. EPERM means it exists but belongs to another user.
|
||||
const isRunning = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
// Delete the temp files `copyAtomic` leaves behind when a backup is killed
|
||||
// before its rename. Only files whose process is no longer running are
|
||||
// removed, so a backup running at the same time keeps its own. A reused
|
||||
// process ID can only keep a leftover a while longer, never remove a live one.
|
||||
const removeLeftoverTempFiles = (dir: string): void => {
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const name of names) {
|
||||
const match = /^\.quak-backup-.*-(\d+)-[0-9a-z]*\.tmp$/.exec(name);
|
||||
if (match && !isRunning(Number(match[1]))) {
|
||||
rmSync(join(dir, name), { force: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
|
||||
// non-symlink entry. Throws on failure (a directory in the way, no permission)
|
||||
// so the caller records it and moves on rather than aborting the run.
|
||||
|
||||
+40
-3
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readdirSync, rmSync } from "node:fs";
|
||||
import { open, rename, rm } from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -180,6 +181,38 @@ export const fsyncPath = async (path: string): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
// A process-ID check: signal 0 delivers nothing and only reports whether the
|
||||
// process exists. EPERM means it exists but belongs to another user.
|
||||
const isRunning = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
// Delete the temp files a killed process left in `dir`: the writer's
|
||||
// `.quak-<pid>-<random>.tmp` and the backup copy's
|
||||
// `.quak-backup-<name>-<pid>-<random>.tmp`. Only files whose process is no
|
||||
// longer running are removed, so another process writing into the same
|
||||
// directory keeps its own. A reused process ID can only keep a leftover a while
|
||||
// longer, never remove a live one.
|
||||
export const removeLeftoverTempFiles = (dir: string): void => {
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const name of names) {
|
||||
const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.tmp$/.exec(name);
|
||||
if (match && !isRunning(Number(match[1]))) {
|
||||
rmSync(join(dir, name), { force: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Stage a write to `destination` atomically and durably, then rename it into
|
||||
// place. `fill` writes the contents into the open temp file handle — either the
|
||||
// whole buffer at once (`writeAtomic`) or chunk by chunk as they decrypt
|
||||
@@ -205,8 +238,12 @@ const stageAtomic = async (
|
||||
): 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(dir, `.quak-${randomUUID()}.tmp`);
|
||||
// from stepping on each other's temporary file; the process ID lets
|
||||
// `removeLeftoverTempFiles` tell a leftover from a write in progress.
|
||||
const tmpPath = join(
|
||||
dir,
|
||||
`.quak-${process.pid}-${randomBytes(16).toString("hex")}.tmp`,
|
||||
);
|
||||
try {
|
||||
const handle = await open(tmpPath, "w");
|
||||
try {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
downloadFile,
|
||||
downloadThumbnail,
|
||||
type ProgressCallback,
|
||||
removeLeftoverTempFiles,
|
||||
} from "../download/index.js";
|
||||
import { safeExtension } from "../filename.js";
|
||||
import type { EnteFile } from "../model/types.js";
|
||||
@@ -46,8 +47,6 @@ import type { Priority, RequestPools } from "./pools.js";
|
||||
|
||||
const DIR_MODE = 0o700;
|
||||
const FILE_MODE = 0o600;
|
||||
const TEMP_PREFIX = ".quak-";
|
||||
const TEMP_SUFFIX = ".tmp";
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
// Owner ruling (#36): bound the originals cache at 100 GiB, but back off when
|
||||
// the volume has under 50 GiB free so the cache never crowds the disk.
|
||||
@@ -654,6 +653,9 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
||||
}
|
||||
|
||||
private async scan(dir: string, into: Map<number, string>): Promise<void> {
|
||||
// Another process sharing this cache may still be writing its temp
|
||||
// files, so only those whose process has exited are removed.
|
||||
removeLeftoverTempFiles(dir);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
@@ -661,12 +663,6 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
||||
return;
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (name.startsWith(TEMP_PREFIX) && name.endsWith(TEMP_SUFFIX)) {
|
||||
await rm(join(dir, name), { force: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const id = fileIDFromName(name);
|
||||
const path = join(dir, name);
|
||||
if (id !== undefined && existsSync(path)) into.set(id, path);
|
||||
|
||||
Reference in New Issue
Block a user