fix(notifications): stabilize active and postprocess-only runs

This commit is contained in:
Sucukdeluxe
2026-08-22 05:38:58 +02:00
parent de97dd4bea
commit 514d1b1d86
2 changed files with 167 additions and 29 deletions
+77 -3
View File
@@ -470,6 +470,7 @@ type RunLifecycleContext = {
id: string;
startedAt: number;
packageGenerations: Map<string, number>;
downloadsFinished: boolean;
};
function generateHistoryId(): string {
@@ -1940,6 +1941,8 @@ export class DownloadManager extends EventEmitter {
private runContexts = new Map<string, RunLifecycleContext>();
private activeRunContextId: string | null = null;
private standalonePackageResults = new Set<string>();
private successDigestResults = new Map<string, PackageResultEnvelope>();
@@ -3005,6 +3008,7 @@ export class DownloadManager extends EventEmitter {
}
}
this.runPackageIds.delete(packageId);
this.untrackActiveRunPackage(packageId);
this.runCompletedPackages.delete(packageId);
} else {
if (pkg.status === "paused") {
@@ -3029,6 +3033,7 @@ export class DownloadManager extends EventEmitter {
if (this.session.running) {
if (hasReactivatedRunItems) {
this.runPackageIds.add(packageId);
this.trackActiveRunPackage(packageId);
}
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`));
}
@@ -3122,6 +3127,7 @@ export class DownloadManager extends EventEmitter {
this.historyRecordedPackages.clear();
this.finalizedPackageResults.clear();
this.runContexts.clear();
this.activeRunContextId = null;
this.standalonePackageResults.clear();
this.successDigestResults.clear();
if (this.successDigestTimer) {
@@ -3239,6 +3245,7 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(itemId);
this.runPackageIds.add(packageId);
this.beginPackageResultGeneration(packageId);
this.trackActiveRunPackage(packageId);
}
if (looksLikeOpaqueFilename(fileName)) {
const existing = unresolvedByLink.get(link) ?? [];
@@ -3373,6 +3380,7 @@ export class DownloadManager extends EventEmitter {
this.abortPackagePostProcessing(packageId, "skip");
this.runPackageIds.delete(packageId);
this.untrackActiveRunPackage(packageId);
this.runCompletedPackages.delete(packageId);
const items = pkg.itemIds
@@ -3451,6 +3459,7 @@ export class DownloadManager extends EventEmitter {
this.runCompletedPackages.delete(packageId);
if (this.session.running) {
this.runPackageIds.add(packageId);
this.trackActiveRunPackage(packageId);
}
pkg.status = "queued";
pkg.updatedAt = nowMs();
@@ -5785,6 +5794,7 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(itemId);
}
this.runPackageIds.add(packageId);
this.trackActiveRunPackage(packageId);
}
await Promise.allSettled(postProcessTasks);
@@ -5867,6 +5877,7 @@ export class DownloadManager extends EventEmitter {
}
if (this.session.running) {
this.runPackageIds.add(pkgId);
this.trackActiveRunPackage(pkgId);
}
}
@@ -5961,6 +5972,11 @@ export class DownloadManager extends EventEmitter {
public async startPackages(packageIds: string[]): Promise<void> {
this.ensureUsableDownloadAccount();
const targetSet = new Set(packageIds);
for (const packageId of this.packagePostProcessTasks.keys()) {
if (targetSet.has(packageId)) {
this.trackStandalonePackageResult(packageId);
}
}
for (const pkgId of targetSet) {
const pkg = this.session.packages[pkgId];
@@ -5990,6 +6006,7 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId);
this.beginPackageResultGeneration(item.packageId);
this.trackActiveRunPackage(item.packageId);
}
}
this.persistSoon();
@@ -6024,6 +6041,7 @@ export class DownloadManager extends EventEmitter {
this.session.running = true;
this.session.paused = false;
this.session.runStartedAt = nowMs();
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
this.session.totalDownloadedBytes = 0;
this.sessionCompletedFiles = 0;
this.session.summaryText = "";
@@ -6062,6 +6080,11 @@ export class DownloadManager extends EventEmitter {
const item = this.session.items[itemId];
if (item) affectedPackageIds.add(item.packageId);
}
for (const packageId of this.packagePostProcessTasks.keys()) {
if (affectedPackageIds.has(packageId)) {
this.trackStandalonePackageResult(packageId);
}
}
for (const pkgId of affectedPackageIds) {
const pkg = this.session.packages[pkgId];
@@ -6095,6 +6118,7 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId);
this.beginPackageResultGeneration(item.packageId);
this.trackActiveRunPackage(item.packageId);
}
}
this.persistSoon();
@@ -6130,6 +6154,7 @@ export class DownloadManager extends EventEmitter {
this.session.running = true;
this.session.paused = false;
this.session.runStartedAt = nowMs();
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
this.session.totalDownloadedBytes = 0;
this.sessionCompletedFiles = 0;
this.session.summaryText = "";
@@ -6167,6 +6192,9 @@ export class DownloadManager extends EventEmitter {
this.schedulerGeneration += 1;
this.session.running = true;
for (const packageId of this.packagePostProcessTasks.keys()) {
this.trackStandalonePackageResult(packageId);
}
const recoveredItems = await this.recoverRetryableItems("start");
@@ -6278,6 +6306,7 @@ export class DownloadManager extends EventEmitter {
this.session.running = true;
this.session.paused = false;
this.session.runStartedAt = nowMs();
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
this.session.totalDownloadedBytes = 0;
this.sessionCompletedFiles = 0;
this.session.summaryText = "";
@@ -11702,16 +11731,54 @@ export class DownloadManager extends EventEmitter {
return next;
}
private captureRunContext(packageIds: Iterable<string>, startedAt: number): RunLifecycleContext {
private createRunContext(packageIds: Iterable<string>, startedAt: number, downloadsFinished: boolean): RunLifecycleContext {
const packageGenerations = new Map<string, number>();
for (const packageId of packageIds) {
packageGenerations.set(packageId, this.getPackageResultGeneration(packageId));
}
const context: RunLifecycleContext = { id: uuidv4(), startedAt, packageGenerations };
const context: RunLifecycleContext = { id: uuidv4(), startedAt, packageGenerations, downloadsFinished };
this.runContexts.set(context.id, context);
return context;
}
private beginActiveRunContext(packageIds: Iterable<string>, startedAt: number): RunLifecycleContext {
const context = this.createRunContext(packageIds, startedAt, false);
this.activeRunContextId = context.id;
return context;
}
private trackActiveRunPackage(packageId: string): void {
if (!this.activeRunContextId) {
return;
}
const context = this.runContexts.get(this.activeRunContextId);
if (context && !context.packageGenerations.has(packageId)) {
context.packageGenerations.set(packageId, this.getPackageResultGeneration(packageId));
}
}
private untrackActiveRunPackage(packageId: string): void {
if (!this.activeRunContextId) {
return;
}
this.runContexts.get(this.activeRunContextId)?.packageGenerations.delete(packageId);
}
private finishActiveRunContext(packageIds: Iterable<string>, startedAt: number): RunLifecycleContext {
const active = this.activeRunContextId ? this.runContexts.get(this.activeRunContextId) : undefined;
const context = active || this.createRunContext(packageIds, startedAt, false);
for (const packageId of packageIds) {
if (!context.packageGenerations.has(packageId)) {
context.packageGenerations.set(packageId, this.getPackageResultGeneration(packageId));
}
}
context.downloadsFinished = true;
if (this.activeRunContextId === context.id) {
this.activeRunContextId = null;
}
return context;
}
private isPackageResultTracked(packageId: string, generation: number): boolean {
const key = this.packageResultKey(packageId, generation);
if (this.standalonePackageResults.has(key)) {
@@ -11725,6 +11792,10 @@ export class DownloadManager extends EventEmitter {
return false;
}
private trackStandalonePackageResult(packageId: string): void {
this.standalonePackageResults.add(this.packageResultKey(packageId, this.getPackageResultGeneration(packageId)));
}
private queueNotificationEvent(notification: NotificationEvent): void {
if (!this.enqueueNotificationCallback || !String(this.settings.notifyUrl || "").trim()) {
return;
@@ -11850,6 +11921,9 @@ export class DownloadManager extends EventEmitter {
private tryFinalizeRunResults(): void {
for (const context of [...this.runContexts.values()]) {
if (!context.downloadsFinished) {
continue;
}
const packageResults: PackageResult[] = [];
let complete = true;
for (const [packageId, generation] of context.packageGenerations) {
@@ -13640,7 +13714,7 @@ export class DownloadManager extends EventEmitter {
averageSpeedBps: avgSpeed
};
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`;
const runContext = total > 0 ? this.captureRunContext(this.runPackageIds, runStartedAt) : null;
const runContext = total > 0 ? this.finishActiveRunContext(this.runPackageIds, runStartedAt) : null;
this.runItemIds.clear();
this.runPackageIds.clear();
this.runOutcomes.clear();
+64
View File
@@ -424,6 +424,70 @@ describe("authoritative run completion", () => {
expect(events[0].payload.title).toContain("Paket-Digest");
});
it("keeps the active run generation after package_done cleanup removes a generation-seven package", async () => {
const { manager, session, events, history } = setup({ completedCleanupPolicy: "package_done" });
const pkg = addPackage(session);
const state = internal(manager);
pkg.resultGeneration = 7;
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.beginActiveRunContext?.(state.runPackageIds, session.runStartedAt);
state.tryFinalizePackageResult(pkg.id);
const currentResult = state.finalizedPackageResults.get(`${pkg.id}:7`);
expect(currentResult).toBeDefined();
state.finalizedPackageResults.set(`${pkg.id}:1`, {
...currentResult,
status: "failed",
successfulFiles: 0,
failedFiles: 9,
failurePhase: "download",
errorCategory: "stale"
});
state.applyPackageDoneCleanup(pkg.id);
expect(session.packages[pkg.id]).toBeUndefined();
state.finishRun();
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1);
const completedRun = events.find((event) => event.type === "run_completed");
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}-7`);
});
it("registers a postprocess-only start before a package task without download items completes", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
for (const itemId of pkg.itemIds) {
delete session.items[itemId];
}
pkg.itemIds = [];
pkg.status = "completed";
let releasePostProcess = (): void => {};
const postProcessGate = new Promise<void>((resolve) => {
releasePostProcess = resolve;
});
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
const postProcess = state.runPackagePostProcessing(pkg.id);
await Promise.resolve();
await manager.start();
releasePostProcess();
await postProcess;
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1);
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({ name: pkg.name, status: "completed", fileCount: 0 });
});
it("finalizes overlapping runs independently when the earlier run finishes deferred work last", async () => {
const { manager, session, events, history } = setup();
const packageA = addPackage(session, ["completed"], "package-a");