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