fix(downloads): reset archive proof before an explicit restart

This commit is contained in:
4gray committed 2026-09-08 01:22:44 +02:00
1 parent 8dfd06c05f
commit 77a70af8e9
6 files changed
+51 -16

No files matched your search

@@ -1,4 +1,4 @@
import { inArray } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { lstatSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import * as schema from '../../database/schema';
@@ -38,6 +38,16 @@ export async function recordArchiveFinalization(
});
}
/** An explicitly restarted transfer must never inherit an earlier attempt's proof. */
export async function clearArchiveFinalization(
db: DownloadsDatabase,
downloadId: number
): Promise<void> {
await db
.delete(schema.downloadArchiveFinalizations)
.where(eq(schema.downloadArchiveFinalizations.downloadId, downloadId));
}
function identity(value: unknown): value is ArchiveFileIdentity {
if (!value || typeof value !== 'object') return false;
const candidate = value as ArchiveFileIdentity;
@@ -1,3 +1,4 @@
import { clearArchiveFinalization } from './download-catchup-journal';
import {
mkdtemp,
readFile,
@@ -26,6 +27,10 @@ import {
} from './download-catchup-limits';
import { stat } from 'node:fs/promises';
jest.mock('./download-catchup-journal', () => ({
...jest.requireActual('./download-catchup-journal'),
clearArchiveFinalization: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('./download-catchup-limits', () => {
const actual = jest.requireActual('./download-catchup-limits');
return {
@@ -118,6 +123,14 @@ describe('TS archive transfer', () => {
};
try {
await writeFile(path + '.part', 'old data');
jest.mocked(clearArchiveFinalization).mockImplementationOnce(
async (_db, id) => {
expect(id).toBe(task.id);
expect(await readFile(path + '.part', 'utf8')).toBe(
'old data'
);
}
);
const actualLimit = jest.requireActual<
typeof import('./download-catchup-limits')
>('./download-catchup-limits').getArchiveByteLimit;
@@ -167,6 +180,10 @@ describe('TS archive transfer', () => {
expect.anything()
);
expect(task.resumeValidator).toBeNull();
expect(clearArchiveFinalization).toHaveBeenCalledWith(
expect.anything(),
task.id
);
} finally {
await rm(dir, { recursive: true, force: true });
}
@@ -1,3 +1,4 @@
import { clearArchiveFinalization } from './download-catchup-journal';
import {
assertArchiveCopyHeadroom,
createArchiveByteGuard,
@@ -70,6 +71,7 @@ export async function transferCatchupToPartialFile(
let output: Awaited<ReturnType<typeof openCatchupOutput>> | undefined;
let pendingProgress = Promise.resolve();
try {
await clearArchiveFinalization(db, task.id);
const response = await requestWithValidatedRedirects<Readable>(
task.url,
{
@@ -1,3 +1,4 @@
import { removePartialDownloadFile } from './download-file-path';
import { constants } from 'node:fs';
import { copyFile, link, stat, unlink } from 'node:fs/promises';
import type { ReservedPartialDownloadFile } from './download-file-path';
@@ -61,3 +62,16 @@ function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean {
errorCode === 'EXDEV'
);
}
/** @returns false when a .part exists but could not be deleted. */
export function removePartialFile(
filePath: string | null | undefined
): boolean {
try {
removePartialDownloadFile(filePath);
return true;
} catch (error) {
console.error('[Downloads] Failed to delete partial file:', error);
return false;
}
}
@@ -1,5 +1,8 @@
import { recordArchiveFinalization } from './download-catchup-journal';
import { finalizePartialDownload } from './download-file-finalize';
import {
finalizePartialDownload,
removePartialFile,
} from './download-file-finalize';
import { cleanupCatchupPartial } from './download-catchup-cleanup';
import {
finalizeCatchupPartial,
@@ -12,7 +15,6 @@ import * as schema from '../../database/schema';
import {
getPartialDownloadPath,
getPartialDownloadSize,
removePartialDownloadFile,
type ReservedPartialDownloadFile,
} from './download-file-path';
import type {
@@ -343,15 +345,4 @@ export function getPausedByteCount(task: DownloadTask): number {
}
}
/** @returns false when a .part exists but could not be deleted. */
export function removePartialFile(
filePath: string | null | undefined
): boolean {
try {
removePartialDownloadFile(filePath);
return true;
} catch (error) {
console.error('[Downloads] Failed to delete partial file:', error);
return false;
}
}
export { removePartialFile } from './download-file-finalize';
+2 -1
View File
@@ -64,7 +64,8 @@ requires that proof and a matching regular file, identity and size to recover an
archive; termination before promotion leaves the verified partial paused. An
owned incomplete copy is removed by journal identity before the source resumes. The
journal also makes startup partial cleanup identity-aware and remains with
completed archives until they are removed. Process-local proof allows immediate
completed archives until they are removed. An explicitly restarted transfer
clears the previous attempt's proof before network requests or partial-file writes. Process-local proof allows immediate
recovery after a transient completion DB error without waiting for a restart.
An explicit cancellation of a queued/paused archive captures the selected regular
partial using the same cleanup helper; symlink entries are preserved.