Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/lifecycle/src/enrichment/dawn-jobs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,4 +521,45 @@ describe('Dawn Growth job orchestration', () => {
expect(deps.recordCleanupAbsence).toHaveBeenCalled();
expect(deps.finishCleanup).not.toHaveBeenCalled();
});

// Production defect: every runaway job had no research_attempt in its payload,
// so the expiresAt deadline never applied and the job deferred every fifteen
// seconds indefinitely (four jobs reached ~2,400 attempts over six days).
const reconciling = (attempts: number) => {
const f = fixture();
f.deps.readDomain.mockRejectedValue(new Error('offline'));
return {
...f,
run: () => f.handlers.enrich(db, { ...job, payload: {}, attempts }, {}),
};
};
const deferredDelay = (deps: { defer: ReturnType<typeof vi.fn> }) =>
(
deps.defer.mock.calls.at(-1)?.[1] as { availableAt: Date }
).availableAt.getTime() - now.getTime();
it('backs reconciliation off exponentially when the payload carries no attempt deadline', async () => {
const first = reconciling(0);
expect(await first.run()).toBe('deferred');
expect(first.deps.fail).not.toHaveBeenCalled();
const second = reconciling(3);
expect(await second.run()).toBe('deferred');
const ceiling = reconciling(9);
expect(await ceiling.run()).toBe('deferred');
const a = deferredDelay(first.deps),
b = deferredDelay(second.deps),
c = deferredDelay(ceiling.deps);
expect(a).toBe(15000);
expect(b).toBeGreaterThan(a);
expect(c).toBeGreaterThan(b);
expect(c).toBeLessThanOrEqual(15 * 60000);
});
it('fails reconciliation for manual review once the attempt cap is reached', async () => {
const capped = reconciling(20);
expect(await capped.run()).toBe('failed');
expect(capped.deps.defer).not.toHaveBeenCalled();
expect(capped.deps.fail).toHaveBeenCalledWith(
db,
expect.objectContaining({ errorCode: 'dawn_reconciliation_exhausted' })
);
});
});
17 changes: 16 additions & 1 deletion apps/lifecycle/src/enrichment/dawn-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ import { createTraceTransport } from '../../../growth-research/src/production/tr

const TERMINAL = new Set(['success', 'error', 'interrupted', 'timeout']);
const RECOVERY_GRACE_MS = 5 * 60000;
// The research_attempt deadline below only exists once an attempt was submitted,
// so a job that keeps failing before submission carries no deadline at all. These
// bounds are the payload-independent terminal condition for that case.
const MAX_RECONCILIATION_ATTEMPTS = 20;
const RECONCILIATION_BASE_DELAY_MS = 15_000;
const RECONCILIATION_MAX_DELAY_MS = 15 * 60_000;
const CLEANUP_HORIZON_MS = 7 * 86400000;
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
Expand Down Expand Up @@ -170,10 +176,19 @@ export function createDawnJobHandlers(
input.now.getTime() >= Date.parse(expiresAt) + RECOVERY_GRACE_MS
)
return fail('dawn_recovery_deadline');
const attempts = job.attempts ?? 0;
if (attempts >= MAX_RECONCILIATION_ATTEMPTS)
return fail('dawn_reconciliation_exhausted');
await d.defer(db, {
...input,
errorCode,
availableAt: new Date(input.now.getTime() + 15000),
availableAt: new Date(
input.now.getTime() +
Math.min(
RECONCILIATION_MAX_DELAY_MS,
RECONCILIATION_BASE_DELAY_MS * 2 ** Math.min(attempts, 10)
)
),
});
return 'deferred' as const;
};
Expand Down
Loading