fix(notifications): stabilize active and postprocess-only runs
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user