From 05afdf4950007bf8d7ef88bc16ae7653bee35671 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 26 Oct 2026 15:46:54 -0400 Subject: [PATCH 1/3] chore(ai-review): record notification test forecast --- ...otification-load-event-count-057e17c1ef19.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .limetech/ai-review-markers/fix-notification-load-event-count-057e17c1ef19.json diff --git a/.limetech/ai-review-markers/fix-notification-load-event-count-057e17c1ef19.json b/.limetech/ai-review-markers/fix-notification-load-event-count-057e17c1ef19.json new file mode 100644 index 0000000000..55c157ebc0 --- /dev/null +++ b/.limetech/ai-review-markers/fix-notification-load-event-count-057e17c1ef19.json @@ -0,0 +1,15 @@ +{ + "disposition": "NOT_REQUIRED", + "expected_structural_delta": "One existing test file. Zero production owners, modules, dependencies or contracts.", + "minimum_design": "Close the watcher after initialization and deliver bounded event batches to the existing real handler. Retain filesystem integration tests and all read-cost and outcome assertions.", + "outcome": "Notification load tests measure fixed event workloads without relying on OS watcher event counts.", + "reuse_decisions": [ + { + "decision": "reuse", + "name": "Existing notification test boundary", + "rationale": "The broadcast tests already close the real watcher after hydration. The load test already instruments handleNotificationEvent and retains real files, counts, reads and subscriptions." + } + ], + "schema": "limetech.ai-review-marker.v2", + "stage": "forecast" +} From 6d11f3a14a28a9159340ab78d38422ca737820c6 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 26 Oct 2026 15:48:26 -0400 Subject: [PATCH 2/3] test(api): make notification load event delivery deterministic --- .../notifications/notifications.load.spec.ts | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.load.spec.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.load.spec.ts index 89c589c349..f2042559b2 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.load.spec.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.load.spec.ts @@ -39,8 +39,8 @@ describe.sequential('Notification burst read costs', () => { [1000, 24], [1000, 100], ]) { - it.each(['create', 'change', 'delete', 'archive-only'] as const)( - `measures ${burstSize} %s events with ${historySize} unread and archived history files`, + it.each(['create', 'change', 'repeated-change', 'delete', 'archive-only'] as const)( + `measures ${burstSize} %s operations with ${historySize} unread and archived history files`, async (operation) => { basePath = await mkdtemp(join(tmpdir(), 'notification-load-')); const path = (type: NotificationType, id: string) => @@ -59,7 +59,8 @@ describe.sequential('Notification burst read costs', () => { ); } const ids = Array.from({ length: burstSize }, (_, index) => `burst-${index}.notify`); - if (operation === 'change' || operation === 'delete') { + const changesExisting = operation === 'change' || operation === 'repeated-change'; + if (changesExisting || operation === 'delete') { await Promise.all( ids.map(async (id) => { await write(UNREAD, id); @@ -73,6 +74,10 @@ describe.sequential('Notification burst read costs', () => { ); await Reflect.get(service, 'initialization'); watcher = Reflect.get(NotificationsService, 'watcher'); + // Measure a fixed handler workload. OS watchers can split or coalesce file writes. + // Real watcher delivery is covered by notifications.overview.spec.ts. + await watcher.close(); + Reflect.set(NotificationsService, 'watcher', null); const reads: string[] = []; const events: { kind: string; path: string }[] = []; const failures: unknown[] = []; @@ -95,13 +100,10 @@ describe.sequential('Notification burst read costs', () => { reads.push(file); return load(file, type); }); - const handle = Reflect.get(service, 'handleNotificationEvent').bind(service); - Reflect.set(service, 'handleNotificationEvent', (file: string, kind: string) => { - events.push({ kind, path: file }); - const pending = handle(file, kind).catch((error: unknown) => failures.push(error)); - handlers.push(pending); - return pending; - }); + const handle: (file: string, kind: string) => Promise = Reflect.get( + service, + 'handleNotificationEvent' + ).bind(service); const publish = Reflect.get(service, 'publishWarningsAndAlerts').bind(service); Reflect.set(service, 'publishWarningsAndAlerts', () => { const pending = publish(); @@ -128,24 +130,37 @@ describe.sequential('Notification burst read costs', () => { case 'create': await write(UNREAD, id, 'warning'); await copyFile(path(UNREAD, id), path(ARCHIVE, id)); + events.push( + { kind: 'add', path: path(UNREAD, id) }, + { kind: 'add', path: path(ARCHIVE, id) } + ); break; case 'change': + case 'repeated-change': await write(UNREAD, id, 'warning'); + events.push({ kind: 'change', path: path(UNREAD, id) }); + if (operation === 'repeated-change') { + events.push({ kind: 'change', path: path(UNREAD, id) }); + } break; case 'delete': await unlink(path(UNREAD, id)); + events.push({ kind: 'unlink', path: path(UNREAD, id) }); break; case 'archive-only': await write(ARCHIVE, id, 'warning'); + events.push({ kind: 'add', path: path(ARCHIVE, id) }); break; } }) ); - const expectedEvents = burstSize * (operation === 'create' ? 2 : 1); - await vi.waitFor(() => expect(events).toHaveLength(expectedEvents), { - timeout: 15000, - }); - await watcher.close(); + const expectedEvents = + burstSize * (operation === 'create' || operation === 'repeated-change' ? 2 : 1); + for (const event of events) { + handlers.push( + handle(event.path, event.kind).catch((error: unknown) => failures.push(error)) + ); + } await Promise.all(handlers); await Promise.all(publications); const elapsedMs = performance.now() - started; @@ -172,7 +187,7 @@ describe.sequential('Notification burst read costs', () => { expect(service.getOverview()).toMatchObject({ unread: { total: unreadTotal, - warning: operation === 'create' || operation === 'change' ? burstSize : 0, + warning: operation === 'create' || changesExisting ? burstSize : 0, }, archive: { total: archiveTotal, alert: operation === 'delete' ? burstSize : 0 }, }); @@ -210,7 +225,7 @@ describe.sequential('Notification burst read costs', () => { operation, historySize, burstSize, - events: expectedEvents, + events: events.length, fileReadAttempts: measuredReads.length, warningScans: scans.mock.calls.length, peakActiveScans, From f2ad0942c0199475b40f63ec9f86b2aea2b02561 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 26 Oct 2026 15:48:57 -0400 Subject: [PATCH 3/3] chore(ai-review): record notification test review --- ...otification-load-event-count-057e17c1ef19.json | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/.limetech/ai-review-markers/fix-notification-load-event-count-057e17c1ef19.json b/.limetech/ai-review-markers/fix-notification-load-event-count-057e17c1ef19.json index 55c157ebc0..7169c0d59e 100644 --- a/.limetech/ai-review-markers/fix-notification-load-event-count-057e17c1ef19.json +++ b/.limetech/ai-review-markers/fix-notification-load-event-count-057e17c1ef19.json @@ -1,15 +1,8 @@ { "disposition": "NOT_REQUIRED", - "expected_structural_delta": "One existing test file. Zero production owners, modules, dependencies or contracts.", - "minimum_design": "Close the watcher after initialization and deliver bounded event batches to the existing real handler. Retain filesystem integration tests and all read-cost and outcome assertions.", - "outcome": "Notification load tests measure fixed event workloads without relying on OS watcher event counts.", - "reuse_decisions": [ - { - "decision": "reuse", - "name": "Existing notification test boundary", - "rationale": "The broadcast tests already close the real watcher after hydration. The load test already instruments handleNotificationEvent and retains real files, counts, reads and subscriptions." - } - ], + "forecast_commit": "05afdf4950007bf8d7ef88bc16ae7653bee35671", + "reviewed_sha": "6d11f3a14a28a9159340ab78d38422ca737820c6", "schema": "limetech.ai-review-marker.v2", - "stage": "forecast" + "stage": "final", + "unresolved_proportionality_findings": [] }