From 36cb60fee9a8aacc13173974f767e9788c4641a3 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 12:31:44 +0200 Subject: [PATCH] fix: harden archive metrics and result retention Derive archive operation identity from item-path provenance so equal basenames in different directories remain distinct, and keep unknown part counts at zero when no item provenance exists. Prune finalized results only after their generation is no longer current or referenced, and add 80-package digest and individual delivery coverage to prove deterministic paging without event loss. --- src/main/download-manager.ts | 37 ++++++++++++++++----- tests/download-manager.test.ts | 59 ++++++++++++++++++++++++++++++++++ tests/notify-hooks.test.ts | 25 ++++++++++++++ 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index db57975..7941aea 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -12265,9 +12265,27 @@ export class DownloadManager extends EventEmitter { pkg.outputProvenance = []; pkg.cleanupErrorCategory = ""; } + this.pruneFinalizedPackageResults(); return next; } + private pruneFinalizedPackageResults(): void { + const retained = new Set(this.standalonePackageResults); + for (const context of this.runContexts.values()) { + for (const [packageId, generation] of context.packageGenerations) { + retained.add(this.packageResultKey(packageId, generation)); + } + } + for (const pkg of Object.values(this.session.packages)) { + retained.add(this.packageResultKey(pkg.id, this.getPackageResultGeneration(pkg.id))); + } + for (const key of this.finalizedPackageResults.keys()) { + if (!retained.has(key)) { + this.finalizedPackageResults.delete(key); + } + } + } + private createRunContext(packageIds: Iterable, startedAt: number, downloadsFinished: boolean): RunLifecycleContext { const packageGenerations = new Map(); for (const packageId of packageIds) { @@ -12654,12 +12672,8 @@ export class DownloadManager extends EventEmitter { this.queueNotificationEvent(buildRunNotificationEvent(result)); } this.runContexts.delete(context.id); - for (const [packageId, generation] of context.packageGenerations) { - if (!this.session.packages[packageId] && !this.isPackageResultTracked(packageId, generation)) { - this.finalizedPackageResults.delete(this.packageResultKey(packageId, generation)); - } - } } + this.pruneFinalizedPackageResults(); } private refreshPackageStatus(pkg: PackageEntry): void { @@ -13051,11 +13065,18 @@ export class DownloadManager extends EventEmitter { } const completedAt = nowMs(); const durationMs = Math.max(0, Math.floor(Number(progress.elapsedMs) || 0)); + const itemIds = [...new Set(items.map((item) => item.id))]; + const itemProvenance = items + .map((item) => String(item.targetPath || item.id).replace(/\\/g, "/").toLocaleLowerCase("de-DE")) + .sort(); + const archiveIdentity = itemProvenance.length > 0 + ? itemProvenance.join("|") + : `${progress.archiveName.toLocaleLowerCase("de-DE")}:${Math.max(0, Math.floor(progress.current))}`; const operation: ArchiveOperationMetric = { - id: `${pkg.id}:${progress.archiveName.toLocaleLowerCase("de-DE")}`, + id: `${pkg.id}:${createHash("sha256").update(archiveIdentity).digest("hex").slice(0, 24)}`, name: progress.archiveName, - itemIds: [...new Set(items.map((item) => item.id))], - partCount: Math.max(1, items.length), + itemIds, + partCount: itemIds.length, startedAt: Math.max(0, completedAt - durationMs), completedAt, durationMs, diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index d6fed80..91b9d5e 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -15114,6 +15114,65 @@ describe("package priority ordering", () => { }); describe("package lifecycle telemetry boundaries", () => { + it("uses item-path provenance for archive identity and leaves unknown part counts at zero", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-identity-")); + tempDirs.push(root); + const session = emptySession(); + const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))); + const pkg: PackageEntry = { + id: "archive-identity-package", + name: "Archive identity", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + status: "completed" as const, + itemIds: ["item-a", "item-b"], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }; + const item = (id: string, directory: string) => ({ + id, + packageId: pkg.id, + url: `https://example.test/${id}`, + provider: "realdebrid" as const, + status: "completed" as const, + retries: 0, + speedBps: 0, + downloadedBytes: 1, + totalBytes: 1, + progressPercent: 100, + fileName: "episode.rar", + targetPath: path.join(pkg.outputDir, directory, "episode.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt: 1_000, + updatedAt: 1_000 + }); + const progress = (current: number) => ({ + current, + total: 3, + percent: 100, + archiveName: "episode.rar", + archivePercent: 100, + elapsedMs: 1_000, + archiveDone: true, + archiveSuccess: true + }); + const state = manager as any; + + state.recordArchiveOperation(pkg, progress(0), [item("item-a", "season-a")]); + state.recordArchiveOperation(pkg, progress(1), [item("item-b", "season-b")]); + state.recordArchiveOperation(pkg, { ...progress(2), archiveName: "unresolved.rar" }, []); + + const operations = pkg.archiveOperations || []; + expect(operations).toHaveLength(3); + expect(new Set(operations.map((operation) => operation.id))).toHaveLength(3); + expect(operations.map((operation) => operation.partCount)).toEqual([1, 1, 0]); + }); + it("records queued, slot start and terminal timestamps around real post-processing", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-lifecycle-boundaries-")); tempDirs.push(root); diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 002c8e7..fe517b1 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -348,6 +348,30 @@ describe("authoritative package completion", () => { expect(events.map((event) => event.type)).toEqual(["package_completed"]); expect(events[0].payload.title).toContain("Paket-Digest"); }); + + it.each([ + ["digest", 4], + ["individual", 80] + ] as const)("delivers 80 successful packages in %s mode without loss", async (mode, expectedEvents) => { + const { manager, session, events } = setup({ notifyPackageSuccessMode: mode }); + const state = internal(manager); + for (let index = 0; index < 80; index += 1) { + const pkg = addPackage(session, ["completed"], `bulk-package-${String(index).padStart(2, "0")}`); + state.runPackageIds.add(pkg.id); + state.tryFinalizePackageResult(pkg.id); + } + if (mode === "digest") { + state.flushPackageSuccessDigest(); + } + await state.notificationEnqueueChain; + + expect(events).toHaveLength(expectedEvents); + expect(new Set(events.map((event) => event.id))).toHaveLength(expectedEvents); + if (mode === "digest") { + expect(events.map((event) => event.payload.fields.length)).toEqual([20, 20, 20, 20]); + expect(events.every((event) => event.payload.description === "80 Pakete abgeschlossen")).toBe(true); + } + }); }); describe("authoritative run completion", () => { @@ -595,6 +619,7 @@ describe("authoritative run completion", () => { expect(completedRun?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true); expect(history).toHaveLength(1); expect(history[0].id).toBe(`hist-${pkg.id}-2`); + expect([...state.finalizedPackageResults.keys()].filter((key) => key.startsWith(`${pkg.id}:`))).toEqual([`${pkg.id}:2`]); }); it("tracks a main postprocess task created by triggerPendingExtractions after start begins", async () => {