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
+103 -29
View File
@@ -470,6 +470,7 @@ type RunLifecycleContext = {
id: string; id: string;
startedAt: number; startedAt: number;
packageGenerations: Map<string, number>; packageGenerations: Map<string, number>;
downloadsFinished: boolean;
}; };
function generateHistoryId(): string { function generateHistoryId(): string {
@@ -1940,6 +1941,8 @@ export class DownloadManager extends EventEmitter {
private runContexts = new Map<string, RunLifecycleContext>(); private runContexts = new Map<string, RunLifecycleContext>();
private activeRunContextId: string | null = null;
private standalonePackageResults = new Set<string>(); private standalonePackageResults = new Set<string>();
private successDigestResults = new Map<string, PackageResultEnvelope>(); private successDigestResults = new Map<string, PackageResultEnvelope>();
@@ -3003,9 +3006,10 @@ export class DownloadManager extends EventEmitter {
item.fullStatus = "Paket gestoppt"; item.fullStatus = "Paket gestoppt";
item.updatedAt = nowMs(); item.updatedAt = nowMs();
} }
} }
this.runPackageIds.delete(packageId); this.runPackageIds.delete(packageId);
this.runCompletedPackages.delete(packageId); this.untrackActiveRunPackage(packageId);
this.runCompletedPackages.delete(packageId);
} else { } else {
if (pkg.status === "paused") { if (pkg.status === "paused") {
pkg.status = "queued"; pkg.status = "queued";
@@ -3027,8 +3031,9 @@ export class DownloadManager extends EventEmitter {
} }
} }
if (this.session.running) { if (this.session.running) {
if (hasReactivatedRunItems) { if (hasReactivatedRunItems) {
this.runPackageIds.add(packageId); this.runPackageIds.add(packageId);
this.trackActiveRunPackage(packageId);
} }
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`)); 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.historyRecordedPackages.clear();
this.finalizedPackageResults.clear(); this.finalizedPackageResults.clear();
this.runContexts.clear(); this.runContexts.clear();
this.activeRunContextId = null;
this.standalonePackageResults.clear(); this.standalonePackageResults.clear();
this.successDigestResults.clear(); this.successDigestResults.clear();
if (this.successDigestTimer) { if (this.successDigestTimer) {
@@ -3239,6 +3245,7 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(itemId); this.runItemIds.add(itemId);
this.runPackageIds.add(packageId); this.runPackageIds.add(packageId);
this.beginPackageResultGeneration(packageId); this.beginPackageResultGeneration(packageId);
this.trackActiveRunPackage(packageId);
} }
if (looksLikeOpaqueFilename(fileName)) { if (looksLikeOpaqueFilename(fileName)) {
const existing = unresolvedByLink.get(link) ?? []; const existing = unresolvedByLink.get(link) ?? [];
@@ -3372,8 +3379,9 @@ export class DownloadManager extends EventEmitter {
this.abortPackagePostProcessing(packageId, "skip"); this.abortPackagePostProcessing(packageId, "skip");
this.runPackageIds.delete(packageId); this.runPackageIds.delete(packageId);
this.runCompletedPackages.delete(packageId); this.untrackActiveRunPackage(packageId);
this.runCompletedPackages.delete(packageId);
const items = pkg.itemIds const items = pkg.itemIds
.map((itemId) => this.session.items[itemId]) .map((itemId) => this.session.items[itemId])
@@ -3449,8 +3457,9 @@ export class DownloadManager extends EventEmitter {
} }
} }
this.runCompletedPackages.delete(packageId); this.runCompletedPackages.delete(packageId);
if (this.session.running) { if (this.session.running) {
this.runPackageIds.add(packageId); this.runPackageIds.add(packageId);
this.trackActiveRunPackage(packageId);
} }
pkg.status = "queued"; pkg.status = "queued";
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
@@ -5785,6 +5794,7 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(itemId); this.runItemIds.add(itemId);
} }
this.runPackageIds.add(packageId); this.runPackageIds.add(packageId);
this.trackActiveRunPackage(packageId);
} }
await Promise.allSettled(postProcessTasks); await Promise.allSettled(postProcessTasks);
@@ -5867,6 +5877,7 @@ export class DownloadManager extends EventEmitter {
} }
if (this.session.running) { if (this.session.running) {
this.runPackageIds.add(pkgId); this.runPackageIds.add(pkgId);
this.trackActiveRunPackage(pkgId);
} }
} }
@@ -5961,6 +5972,11 @@ export class DownloadManager extends EventEmitter {
public async startPackages(packageIds: string[]): Promise<void> { public async startPackages(packageIds: string[]): Promise<void> {
this.ensureUsableDownloadAccount(); this.ensureUsableDownloadAccount();
const targetSet = new Set(packageIds); const targetSet = new Set(packageIds);
for (const packageId of this.packagePostProcessTasks.keys()) {
if (targetSet.has(packageId)) {
this.trackStandalonePackageResult(packageId);
}
}
for (const pkgId of targetSet) { for (const pkgId of targetSet) {
const pkg = this.session.packages[pkgId]; const pkg = this.session.packages[pkgId];
@@ -5990,6 +6006,7 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(item.id); this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId); this.runPackageIds.add(item.packageId);
this.beginPackageResultGeneration(item.packageId); this.beginPackageResultGeneration(item.packageId);
this.trackActiveRunPackage(item.packageId);
} }
} }
this.persistSoon(); this.persistSoon();
@@ -6021,9 +6038,10 @@ export class DownloadManager extends EventEmitter {
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.session.running = true; this.session.running = true;
this.session.paused = false; this.session.paused = false;
this.session.runStartedAt = nowMs(); this.session.runStartedAt = nowMs();
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
this.session.totalDownloadedBytes = 0; this.session.totalDownloadedBytes = 0;
this.sessionCompletedFiles = 0; this.sessionCompletedFiles = 0;
this.session.summaryText = ""; this.session.summaryText = "";
@@ -6057,11 +6075,16 @@ export class DownloadManager extends EventEmitter {
this.ensureUsableDownloadAccount(); this.ensureUsableDownloadAccount();
const targetSet = new Set(itemIds); const targetSet = new Set(itemIds);
const affectedPackageIds = new Set<string>(); const affectedPackageIds = new Set<string>();
for (const itemId of targetSet) { for (const itemId of targetSet) {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
if (item) affectedPackageIds.add(item.packageId); 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) { for (const pkgId of affectedPackageIds) {
const pkg = this.session.packages[pkgId]; const pkg = this.session.packages[pkgId];
@@ -6095,6 +6118,7 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(item.id); this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId); this.runPackageIds.add(item.packageId);
this.beginPackageResultGeneration(item.packageId); this.beginPackageResultGeneration(item.packageId);
this.trackActiveRunPackage(item.packageId);
} }
} }
this.persistSoon(); this.persistSoon();
@@ -6127,9 +6151,10 @@ export class DownloadManager extends EventEmitter {
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.session.running = true; this.session.running = true;
this.session.paused = false; this.session.paused = false;
this.session.runStartedAt = nowMs(); this.session.runStartedAt = nowMs();
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
this.session.totalDownloadedBytes = 0; this.session.totalDownloadedBytes = 0;
this.sessionCompletedFiles = 0; this.sessionCompletedFiles = 0;
this.session.summaryText = ""; this.session.summaryText = "";
@@ -6166,9 +6191,12 @@ export class DownloadManager extends EventEmitter {
this.ensureUsableDownloadAccount(); this.ensureUsableDownloadAccount();
this.schedulerGeneration += 1; this.schedulerGeneration += 1;
this.session.running = true; this.session.running = true;
for (const packageId of this.packagePostProcessTasks.keys()) {
const recoveredItems = await this.recoverRetryableItems("start"); this.trackStandalonePackageResult(packageId);
}
const recoveredItems = await this.recoverRetryableItems("start");
await sleep(0); await sleep(0);
@@ -6275,9 +6303,10 @@ export class DownloadManager extends EventEmitter {
} }
} }
this.session.running = true; this.session.running = true;
this.session.paused = false; this.session.paused = false;
this.session.runStartedAt = nowMs(); this.session.runStartedAt = nowMs();
this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt);
this.session.totalDownloadedBytes = 0; this.session.totalDownloadedBytes = 0;
this.sessionCompletedFiles = 0; this.sessionCompletedFiles = 0;
this.session.summaryText = ""; this.session.summaryText = "";
@@ -11702,16 +11731,54 @@ export class DownloadManager extends EventEmitter {
return next; 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>(); const packageGenerations = new Map<string, number>();
for (const packageId of packageIds) { for (const packageId of packageIds) {
packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); 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); this.runContexts.set(context.id, context);
return 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 { private isPackageResultTracked(packageId: string, generation: number): boolean {
const key = this.packageResultKey(packageId, generation); const key = this.packageResultKey(packageId, generation);
if (this.standalonePackageResults.has(key)) { if (this.standalonePackageResults.has(key)) {
@@ -11725,6 +11792,10 @@ export class DownloadManager extends EventEmitter {
return false; return false;
} }
private trackStandalonePackageResult(packageId: string): void {
this.standalonePackageResults.add(this.packageResultKey(packageId, this.getPackageResultGeneration(packageId)));
}
private queueNotificationEvent(notification: NotificationEvent): void { private queueNotificationEvent(notification: NotificationEvent): void {
if (!this.enqueueNotificationCallback || !String(this.settings.notifyUrl || "").trim()) { if (!this.enqueueNotificationCallback || !String(this.settings.notifyUrl || "").trim()) {
return; return;
@@ -11850,6 +11921,9 @@ export class DownloadManager extends EventEmitter {
private tryFinalizeRunResults(): void { private tryFinalizeRunResults(): void {
for (const context of [...this.runContexts.values()]) { for (const context of [...this.runContexts.values()]) {
if (!context.downloadsFinished) {
continue;
}
const packageResults: PackageResult[] = []; const packageResults: PackageResult[] = [];
let complete = true; let complete = true;
for (const [packageId, generation] of context.packageGenerations) { for (const [packageId, generation] of context.packageGenerations) {
@@ -13640,7 +13714,7 @@ export class DownloadManager extends EventEmitter {
averageSpeedBps: avgSpeed averageSpeedBps: avgSpeed
}; };
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; 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.runItemIds.clear();
this.runPackageIds.clear(); this.runPackageIds.clear();
this.runOutcomes.clear(); this.runOutcomes.clear();
+64
View File
@@ -424,6 +424,70 @@ describe("authoritative run completion", () => {
expect(events[0].payload.title).toContain("Paket-Digest"); 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 () => { it("finalizes overlapping runs independently when the earlier run finishes deferred work last", async () => {
const { manager, session, events, history } = setup(); const { manager, session, events, history } = setup();
const packageA = addPackage(session, ["completed"], "package-a"); const packageA = addPackage(session, ["completed"], "package-a");