-
Notifications
You must be signed in to change notification settings - Fork 0
feat: self-healing monitor auto-retry after transient failures #302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,6 +8,7 @@ const { | |||||||||||||||||||||||||||||||||||||
| mockGetAllActiveMonitors, | ||||||||||||||||||||||||||||||||||||||
| mockCleanupPollutedValues, | ||||||||||||||||||||||||||||||||||||||
| mockDbExecute, | ||||||||||||||||||||||||||||||||||||||
| mockDbUpdateSet, | ||||||||||||||||||||||||||||||||||||||
| cronCallbacks, | ||||||||||||||||||||||||||||||||||||||
| mockMonitorsNeedingRetry, | ||||||||||||||||||||||||||||||||||||||
| mockDeliverWebhook, | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -16,6 +17,7 @@ const { | |||||||||||||||||||||||||||||||||||||
| mockGetAllActiveMonitors: vi.fn().mockResolvedValue([]), | ||||||||||||||||||||||||||||||||||||||
| mockCleanupPollutedValues: vi.fn().mockResolvedValue(undefined), | ||||||||||||||||||||||||||||||||||||||
| mockDbExecute: vi.fn().mockResolvedValue({ rowCount: 0 }), | ||||||||||||||||||||||||||||||||||||||
| mockDbUpdateSet: vi.fn(), | ||||||||||||||||||||||||||||||||||||||
| cronCallbacks: {} as Record<string, Array<() => Promise<void>>>, | ||||||||||||||||||||||||||||||||||||||
| mockMonitorsNeedingRetry: new Set<number>(), | ||||||||||||||||||||||||||||||||||||||
| mockDeliverWebhook: vi.fn().mockResolvedValue({ success: true, statusCode: 200 }), | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -60,6 +62,14 @@ vi.mock("./logger", () => ({ | |||||||||||||||||||||||||||||||||||||
| vi.mock("../db", () => ({ | ||||||||||||||||||||||||||||||||||||||
| db: { | ||||||||||||||||||||||||||||||||||||||
| execute: (...args: any[]) => mockDbExecute(...args), | ||||||||||||||||||||||||||||||||||||||
| update: vi.fn().mockReturnValue({ | ||||||||||||||||||||||||||||||||||||||
| set: (...args: any[]) => { | ||||||||||||||||||||||||||||||||||||||
| mockDbUpdateSet(...args); | ||||||||||||||||||||||||||||||||||||||
| const whereResult = Promise.resolve(); | ||||||||||||||||||||||||||||||||||||||
| const whereFn = vi.fn().mockReturnValue(whereResult); | ||||||||||||||||||||||||||||||||||||||
| return { where: whereFn }; | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+65
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Capture and assert the Line 70 returns a Suggested hardening for the DB update mock and assertions const {
mockCheckMonitor,
mockGetAllActiveMonitors,
mockCleanupPollutedValues,
mockDbExecute,
mockDbUpdateSet,
+ mockDbUpdateWhere,
cronCallbacks,
mockMonitorsNeedingRetry,
mockDeliverWebhook,
} = vi.hoisted(() => ({
@@
mockDbExecute: vi.fn().mockResolvedValue({ rowCount: 0 }),
mockDbUpdateSet: vi.fn(),
+ mockDbUpdateWhere: vi.fn(),
@@
update: vi.fn().mockReturnValue({
set: (...args: any[]) => {
mockDbUpdateSet(...args);
- const whereResult = Promise.resolve();
- const whereFn = vi.fn().mockReturnValue(whereResult);
+ const whereFn = vi.fn((...whereArgs: any[]) => {
+ mockDbUpdateWhere(...whereArgs);
+ return Promise.resolve();
+ });
return { where: whereFn };
},
}), expect(mockDbUpdateSet).toHaveBeenCalledWith({ pendingRetryAt: null });
+expect(mockDbUpdateWhere).toHaveBeenCalled();📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
@@ -163,6 +173,7 @@ function makeMonitor(overrides: Partial<Monitor> = {}): Monitor { | |||||||||||||||||||||||||||||||||||||
| pauseReason: null, | ||||||||||||||||||||||||||||||||||||||
| healthAlertSentAt: null, | ||||||||||||||||||||||||||||||||||||||
| lastHealthyAt: null, | ||||||||||||||||||||||||||||||||||||||
| pendingRetryAt: null, | ||||||||||||||||||||||||||||||||||||||
| createdAt: new Date(), | ||||||||||||||||||||||||||||||||||||||
| ...overrides, | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -399,6 +410,75 @@ describe("startScheduler", () => { | |||||||||||||||||||||||||||||||||||||
| resolver!(); | ||||||||||||||||||||||||||||||||||||||
| await Promise.resolve(); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // ----------------------------------------------------------------------- | ||||||||||||||||||||||||||||||||||||||
| // auto-retry scheduler pickup (pendingRetryAt) | ||||||||||||||||||||||||||||||||||||||
| // ----------------------------------------------------------------------- | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| it("triggers check for monitor with pendingRetryAt <= now", async () => { | ||||||||||||||||||||||||||||||||||||||
| const monitor = makeMonitor({ | ||||||||||||||||||||||||||||||||||||||
| frequency: "hourly", | ||||||||||||||||||||||||||||||||||||||
| lastChecked: new Date(Date.now() - 30 * 60 * 1000), // 30 min ago — not normally due | ||||||||||||||||||||||||||||||||||||||
| pendingRetryAt: new Date(Date.now() - 1000), // 1 second in the past | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| mockGetAllActiveMonitors.mockResolvedValueOnce([monitor]); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| await startScheduler(); | ||||||||||||||||||||||||||||||||||||||
| await runCron("* * * * *"); | ||||||||||||||||||||||||||||||||||||||
| await vi.advanceTimersByTimeAsync(31000); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| expect(mockCheckMonitor).toHaveBeenCalledWith(monitor); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| it("does NOT trigger check for monitor with pendingRetryAt in the future", async () => { | ||||||||||||||||||||||||||||||||||||||
| const monitor = makeMonitor({ | ||||||||||||||||||||||||||||||||||||||
| frequency: "hourly", | ||||||||||||||||||||||||||||||||||||||
| lastChecked: new Date(Date.now() - 30 * 60 * 1000), // 30 min ago — not normally due | ||||||||||||||||||||||||||||||||||||||
| pendingRetryAt: new Date(Date.now() + 30 * 60 * 1000), // 30 min in the future | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| mockGetAllActiveMonitors.mockResolvedValueOnce([monitor]); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| await startScheduler(); | ||||||||||||||||||||||||||||||||||||||
| await runCron("* * * * *"); | ||||||||||||||||||||||||||||||||||||||
| await vi.advanceTimersByTimeAsync(31000); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| expect(mockCheckMonitor).not.toHaveBeenCalled(); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+418
to
+446
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add explicit boundary test for You cover past and future, but not the exact boundary for the Boundary test to add+ it("triggers check for monitor with pendingRetryAt exactly now", async () => {
+ const now = new Date();
+ vi.setSystemTime(now);
+ const monitor = makeMonitor({
+ frequency: "hourly",
+ lastChecked: new Date(now.getTime() - 30 * 60 * 1000),
+ pendingRetryAt: now,
+ });
+ mockGetAllActiveMonitors.mockResolvedValueOnce([monitor]);
+
+ await startScheduler();
+ await runCron("* * * * *");
+ await vi.advanceTimersByTimeAsync(31000);
+
+ expect(mockCheckMonitor).toHaveBeenCalledWith(monitor);
+ });As per coding guidelines, "Tests must cover edge cases and error paths - include assertions for edge cases (empty inputs, boundary values, null/undefined)". 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| it("clears pendingRetryAt after retry fires (success path)", async () => { | ||||||||||||||||||||||||||||||||||||||
| const monitor = makeMonitor({ | ||||||||||||||||||||||||||||||||||||||
| frequency: "hourly", | ||||||||||||||||||||||||||||||||||||||
| lastChecked: new Date(Date.now() - 30 * 60 * 1000), | ||||||||||||||||||||||||||||||||||||||
| pendingRetryAt: new Date(Date.now() - 1000), | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| mockGetAllActiveMonitors.mockResolvedValueOnce([monitor]); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| await startScheduler(); | ||||||||||||||||||||||||||||||||||||||
| await runCron("* * * * *"); | ||||||||||||||||||||||||||||||||||||||
| await vi.advanceTimersByTimeAsync(31000); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| expect(mockCheckMonitor).toHaveBeenCalledWith(monitor); | ||||||||||||||||||||||||||||||||||||||
| // The finally block should clear pendingRetryAt | ||||||||||||||||||||||||||||||||||||||
| expect(mockDbUpdateSet).toHaveBeenCalledWith({ pendingRetryAt: null }); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+456
to
+463
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "scheduler.test.ts" -type f | head -5Repository: bd73-com/fetchthechange Length of output: 102 🏁 Script executed: wc -l server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 105 🏁 Script executed: sed -n '450,485p' server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 1361 🏁 Script executed: find . -name "scheduler.ts" -type f | grep -E "server/services" | head -5Repository: bd73-com/fetchthechange Length of output: 97 🏁 Script executed: sed -n '1,100p' server/services/scheduler.ts | head -80Repository: bd73-com/fetchthechange Length of output: 3179 🏁 Script executed: rg "finally" server/services/scheduler.ts -B3 -A3Repository: bd73-com/fetchthechange Length of output: 780 🏁 Script executed: rg "pendingRetryAt" server/services/scheduler.ts -B3 -A3Repository: bd73-com/fetchthechange Length of output: 800 🏁 Script executed: rg "runCheckWithLimit" server/services/scheduler.ts -B5 -A15Repository: bd73-com/fetchthechange Length of output: 2613 🏁 Script executed: head -50 server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 2009 🏁 Script executed: sed -n '1,200p' server/services/scheduler.test.ts | grep -E "import|flushPromises"Repository: bd73-com/fetchthechange Length of output: 602 🏁 Script executed: rg "flushPromises" server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 177 🏁 Script executed: rg "vi.runAllTimers|vi.advanceTimers" server/services/scheduler.test.ts | head -20Repository: bd73-com/fetchthechange Length of output: 1010 🏁 Script executed: sed -n '440,485p' server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 1640 🏁 Script executed: rg "void runCheckWithLimit" server/services/scheduler.ts -B2 -A2Repository: bd73-com/fetchthechange Length of output: 624 🏁 Script executed: sed -n '9,30p' server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 915 🏁 Script executed: rg "async function flushPromises" server/services/scheduler.test.ts -A 10Repository: bd73-com/fetchthechange Length of output: 418 🏁 Script executed: sed -n '456,495p' server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 1404 🏁 Script executed: sed -n '80,150p' server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 2414 🏁 Script executed: rg "vi.mock.*db" server/services/scheduler.test.ts -A 30Repository: bd73-com/fetchthechange Length of output: 831 🏁 Script executed: sed -n '448,490p' server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 1595 🏁 Script executed: rg "await flushPromises" server/services/scheduler.test.ts | head -20Repository: bd73-com/fetchthechange Length of output: 120 🏁 Script executed: rg -n "await flushPromises\(\)" server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 130 🏁 Script executed: sed -n '300,350p' server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 1909 🏁 Script executed: sed -n '1040,1075p' server/services/scheduler.test.tsRepository: bd73-com/fetchthechange Length of output: 1547 Add Both tests at lines 456–463 (success path) and 474–480 (failure path) invoke Add microtask flush before assertions await startScheduler();
await runCron("* * * * *");
await vi.advanceTimersByTimeAsync(31000);
+ await flushPromises();
expect(mockCheckMonitor).toHaveBeenCalledWith(monitor);
// The finally block should clear pendingRetryAt
expect(mockDbUpdateSet).toHaveBeenCalledWith({ pendingRetryAt: null });
@@
await startScheduler();
await runCron("* * * * *");
await vi.advanceTimersByTimeAsync(31000);
+ await flushPromises();
expect(mockCheckMonitor).toHaveBeenCalledWith(monitor);
// Even on failure, pendingRetryAt should be cleared
expect(mockDbUpdateSet).toHaveBeenCalledWith({ pendingRetryAt: null });🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| it("clears pendingRetryAt after retry fires (failure path)", async () => { | ||||||||||||||||||||||||||||||||||||||
| mockCheckMonitor.mockRejectedValueOnce(new Error("Scrape failed")); | ||||||||||||||||||||||||||||||||||||||
| const monitor = makeMonitor({ | ||||||||||||||||||||||||||||||||||||||
| frequency: "hourly", | ||||||||||||||||||||||||||||||||||||||
| lastChecked: new Date(Date.now() - 30 * 60 * 1000), | ||||||||||||||||||||||||||||||||||||||
| pendingRetryAt: new Date(Date.now() - 1000), | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| mockGetAllActiveMonitors.mockResolvedValueOnce([monitor]); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| await startScheduler(); | ||||||||||||||||||||||||||||||||||||||
| await runCron("* * * * *"); | ||||||||||||||||||||||||||||||||||||||
| await vi.advanceTimersByTimeAsync(31000); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| expect(mockCheckMonitor).toHaveBeenCalledWith(monitor); | ||||||||||||||||||||||||||||||||||||||
| // Even on failure, pendingRetryAt should be cleared | ||||||||||||||||||||||||||||||||||||||
| expect(mockDbUpdateSet).toHaveBeenCalledWith({ pendingRetryAt: null }); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| describe("concurrency limiting (runCheckWithLimit)", () => { | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -1290,3 +1370,4 @@ describe("webhook retry cumulative backoff", () => { | |||||||||||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't fail open when clearing
pendingRetryAt.If this write fails, the manual check still runs while the due retry remains armed, so the scheduler can execute the same monitor again and duplicate change rows or notifications. This guard should abort the request on failure, and the write should stay behind
IStorage.💡 Safer change
🤖 Prompt for AI Agents