fix(report): keep personal-report heartbeats ref'd during chapter writes
Independent Staging Quality Gate / validate (push) Successful in 7m56s
Independent Staging Quality Gate / publish (push) Successful in 1m49s

Staging lost the job lease mid-chapter because the unref'd interval never
fired outside an HTTP request. Refresh the lease on section progress and
leave the heartbeat timer ref'd so a long model await cannot starve it.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-03 02:13:13 +08:00
parent 4329426530
commit 5c0bec0c94
5 changed files with 46 additions and 21 deletions
+15 -14
View File
@@ -165,11 +165,6 @@ function reportFailureCode(code: PersonalReportWorkerErrorCode): PersonalReportF
}
}
function timerUnref(timer: ReturnType<typeof globalThis.setInterval>): void {
const candidate = timer as ReturnType<typeof globalThis.setInterval> & { unref?: () => void };
candidate.unref?.();
}
function defaultRetryDelayMs(job: PersonalReportJobRecord): number {
const exponent = Math.max(0, Math.min(job.attemptCount - 1, 5));
return 5_000 * (2 ** exponent);
@@ -269,22 +264,26 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
const controller = new AbortController();
let heartbeatError: unknown = null;
let heartbeatChain = Promise.resolve();
const refreshLease = async () => {
if (heartbeatError !== null || controller.signal.aborted) return;
await deps.jobs.heartbeatLease({
jobId: job.id,
leaseToken: job.leaseToken!,
leaseSeconds,
});
};
const heartbeatTimer = setIntervalFn(() => {
heartbeatChain = heartbeatChain
.then(async () => {
if (heartbeatError !== null || controller.signal.aborted) return;
await deps.jobs.heartbeatLease({
jobId: job.id,
leaseToken: job.leaseToken!,
leaseSeconds,
});
})
.then(refreshLease)
.catch((error) => {
heartbeatError = error;
controller.abort(error);
});
}, heartbeatIntervalMs);
timerUnref(heartbeatTimer);
// Keep this timer ref'd. The worker runs from instrumentation, not an HTTP
// request; unref'd intervals in Next.js standalone are skipped while the
// only in-flight work is a long model await, which is exactly when the
// lease must be extended.
let report: PersonalReportRecord | null = null;
const generationStartedAt = now().getTime();
@@ -339,6 +338,8 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
signal: controller.signal,
sectionService: deps.sectionService,
onProgress: async (progress) => {
await refreshLease();
if (heartbeatError !== null) throw heartbeatError;
const percent = progress.total > 0
? Math.min(89, 30 + Math.floor((progress.completed / progress.total) * 55))
: 30;