Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c4b1185b |
+11
-3
@@ -23,7 +23,10 @@
|
|||||||
// download or a failed symlink is caught, recorded in `failures.json` with a
|
// download or a failed symlink is caught, recorded in `failures.json` with a
|
||||||
// classification, a running attempt count, and the last-tried time, and the run
|
// classification, a running attempt count, and the last-tried time, and the run
|
||||||
// continues. `result.failed` — and thus the CLI's exit code — stays non-zero
|
// continues. `result.failed` — and thus the CLI's exit code — stays non-zero
|
||||||
// while any failure remains unresolved and clears once every one succeeds.
|
// while any failure remains unresolved and clears once every one succeeds. Each
|
||||||
|
// run reconciles the ledger against the files it attempted, so an entry for a
|
||||||
|
// file that has since left the library (deleted) or this run's scope is dropped
|
||||||
|
// rather than counted forever, which would poison a scheduled backup's exit code.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
copyFileSync,
|
copyFileSync,
|
||||||
@@ -398,8 +401,13 @@ export const runBackup = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A file in scope that did not fail this run has no unresolved failure.
|
// Reconcile the ledger against what this run actually attempted: an entry
|
||||||
for (const fileID of distinct.keys()) {
|
// survives only for a file that failed this run. A file that succeeded had
|
||||||
|
// its failure resolved; a file gone from the library (deleted) or outside
|
||||||
|
// this run's scope is not something this run can resolve, so keeping its
|
||||||
|
// stale entry would keep the exit code non-zero forever — a single
|
||||||
|
// since-deleted photo would fail every future scheduled backup.
|
||||||
|
for (const fileID of [...ledger.keys()]) {
|
||||||
if (!failedThisRun.has(fileID)) ledger.delete(fileID);
|
if (!failedThisRun.has(fileID)) ledger.delete(fileID);
|
||||||
}
|
}
|
||||||
saveLedger(ledgerPath, ledger);
|
saveLedger(ledgerPath, ledger);
|
||||||
|
|||||||
@@ -147,6 +147,28 @@ const readLedger = (
|
|||||||
): { files: Record<string, Record<string, unknown>> } =>
|
): { files: Record<string, Record<string, unknown>> } =>
|
||||||
JSON.parse(readFileSync(join(outDir, "failures.json"), "utf-8"));
|
JSON.parse(readFileSync(join(outDir, "failures.json"), "utf-8"));
|
||||||
|
|
||||||
|
// Write a durable ledger holding one prior failure, to exercise pruning of
|
||||||
|
// entries the current run cannot resolve.
|
||||||
|
const seedLedger = (outDir: string, fileID: number, title: string): void => {
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(outDir, "failures.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
files: {
|
||||||
|
[String(fileID)]: {
|
||||||
|
fileID,
|
||||||
|
title,
|
||||||
|
classification: "transient",
|
||||||
|
attempts: 1,
|
||||||
|
lastTriedAt: Date.now(),
|
||||||
|
error: "HTTP 500 from server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
root = mkdtempSync(join(tmpdir(), "quak-backup-test-"));
|
root = mkdtempSync(join(tmpdir(), "quak-backup-test-"));
|
||||||
});
|
});
|
||||||
@@ -377,6 +399,47 @@ describe("lib.backup", () => {
|
|||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prunes a ledger entry for a file no longer in the library and exits zero", async () => {
|
||||||
|
const source = stubSource();
|
||||||
|
const lib = await openLibrary(source);
|
||||||
|
const outDir = join(root, "backup");
|
||||||
|
|
||||||
|
// A prior failure for a file that has since left the library (deleted
|
||||||
|
// from the account). This run has no way to resolve it, so it must not
|
||||||
|
// keep the exit code non-zero forever.
|
||||||
|
seedLedger(outDir, 999, "gone.jpg");
|
||||||
|
|
||||||
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
|
// Everything still present is backed up cleanly, and the stale entry is
|
||||||
|
// dropped rather than counted.
|
||||||
|
expect(result.downloaded).toBe(3);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
||||||
|
lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes an out-of-scope ledger entry on a scoped run and exits zero", async () => {
|
||||||
|
const source = stubSource();
|
||||||
|
const lib = await openLibrary(source);
|
||||||
|
const outDir = join(root, "backup");
|
||||||
|
|
||||||
|
// A prior failure for a Vacation file; this run is scoped to Work and
|
||||||
|
// never attempts it, so it must not poison the scoped run's exit code.
|
||||||
|
seedLedger(outDir, 100, "beach.jpg");
|
||||||
|
|
||||||
|
const result = await lib.backup({
|
||||||
|
downloadDirectory: outDir,
|
||||||
|
onlyAlbumNames: ["Work"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.totalFiles).toBe(1);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
||||||
|
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
||||||
|
lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("also stores thumbnails when includeThumbnails is set", async () => {
|
it("also stores thumbnails when includeThumbnails is set", async () => {
|
||||||
const source = stubSource();
|
const source = stubSource();
|
||||||
const lib = await openLibrary(source);
|
const lib = await openLibrary(source);
|
||||||
|
|||||||
Reference in New Issue
Block a user