@detiq/crawler 1.1.25 → 1.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/job-queue.ts +264 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.25",
3
+ "version": "1.1.26",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/job-queue.ts CHANGED
@@ -67,6 +67,70 @@ import fs from 'node:fs/promises';
67
67
  import path from 'node:path';
68
68
  import os from 'node:os';
69
69
 
70
+ // ── GAP-14/15: Structured failure classification with per-type backoff ────────────────
71
+
72
+ export enum FailureType {
73
+ TRANSIENT = 'TRANSIENT', // network timeout, rate limit, 429, 503
74
+ INFRASTRUCTURE = 'INFRASTRUCTURE', // DB connection, OOM, worker crash
75
+ LOGICAL = 'LOGICAL', // bad input, business rule violation, 400/422
76
+ FATAL = 'FATAL', // unrecoverable — bad config, missing secrets
77
+ }
78
+
79
+ interface BackoffConfig {
80
+ maxRetries: number;
81
+ baseDelayMs: number;
82
+ multiplier: number;
83
+ maxDelayMs: number;
84
+ }
85
+
86
+ const BACKOFF_BY_TYPE: Record<FailureType, BackoffConfig> = {
87
+ [FailureType.TRANSIENT]: { maxRetries: 5, baseDelayMs: 30_000, multiplier: 2, maxDelayMs: 300_000 },
88
+ [FailureType.INFRASTRUCTURE]: { maxRetries: 3, baseDelayMs: 60_000, multiplier: 3, maxDelayMs: 600_000 },
89
+ [FailureType.LOGICAL]: { maxRetries: 1, baseDelayMs: 120_000, multiplier: 1, maxDelayMs: 120_000 },
90
+ [FailureType.FATAL]: { maxRetries: 0, baseDelayMs: 0, multiplier: 1, maxDelayMs: 0 },
91
+ };
92
+
93
+ export function classifyFailure(error: string | Error | unknown): FailureType {
94
+ const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();
95
+
96
+ // Fatal — bad configuration, missing secrets
97
+ if (msg.includes('master_enc_key') || msg.includes('missing secret') || msg.includes('invalid configuration')) {
98
+ return FailureType.FATAL;
99
+ }
100
+
101
+ // Infrastructure — OOM, DB, worker crashes
102
+ if (
103
+ msg.includes('out of memory') || msg.includes('heap out') ||
104
+ msg.includes('connection refused') || msg.includes('econnrefused') ||
105
+ msg.includes('prisma') || msg.includes('database') ||
106
+ msg.includes('worker exited') || msg.includes('sigkill')
107
+ ) {
108
+ return FailureType.INFRASTRUCTURE;
109
+ }
110
+
111
+ // Logical — validation, auth, bad input
112
+ if (
113
+ msg.includes('validation') || msg.includes('invalid input') ||
114
+ msg.includes('not found') || msg.includes('unauthorized') ||
115
+ msg.includes('bad request') || msg.includes('400') || msg.includes('422') ||
116
+ msg.includes('403')
117
+ ) {
118
+ return FailureType.LOGICAL;
119
+ }
120
+
121
+ // Default: TRANSIENT (network, timeout, rate limit, 429, 503)
122
+ return FailureType.TRANSIENT;
123
+ }
124
+
125
+ function computeNextRetryAt(failureType: FailureType, retryCount: number): Date | null {
126
+ const config = BACKOFF_BY_TYPE[failureType];
127
+ if (retryCount >= config.maxRetries) return null; // exceeded max — go to DLQ
128
+ const delayMs = Math.min(config.baseDelayMs * Math.pow(config.multiplier, retryCount), config.maxDelayMs);
129
+ return new Date(Date.now() + delayMs);
130
+ }
131
+
132
+ // ────────────────────────────────────────────────────────────────────────────────
133
+
70
134
  // RFC 6238 TOTP — no external dependency, uses Node built-in crypto.
71
135
  export function generateTOTP(secret: string): string {
72
136
  const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
@@ -227,8 +291,24 @@ const runningControllers = new Map<string, AbortController>();
227
291
 
228
292
  const WORKER_ID = `${os.hostname()}:${process.pid}`;
229
293
  const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
294
+ const MAX_CONCURRENT_PER_TENANT = Number(process.env.MAX_CONCURRENT_PER_TENANT) || 4;
230
295
  const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15_000;
231
296
  const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
297
+
298
+ // GAP-12: true active-execution semaphore — counts jobs currently executing
299
+ // (claim→finish), not poll slots. A single poll loop checks this before every
300
+ // claim so the cap is never exceeded even across overlapping ticks.
301
+ let activeExecutions = 0;
302
+
303
+ function withSemaphore<T>(fn: () => Promise<T>): Promise<T> {
304
+ activeExecutions++;
305
+ return fn().finally(() => { activeExecutions--; });
306
+ }
307
+
308
+ export function getActiveExecutionCount(): number {
309
+ return activeExecutions;
310
+ }
311
+
232
312
  // Max simultaneous DISCOVER jobs — each launches a Playwright browser and holds
233
313
  // DB connections, so running many concurrently exhausts resources. Default 2.
234
314
  const MAX_CONCURRENT_DISCOVER = Number(process.env.MAX_CONCURRENT_DISCOVER) || 2;
@@ -252,6 +332,7 @@ export async function claimSpecificJob(jobId: string): Promise<ClaimedJob | null
252
332
  UPDATE "PlatformJob"
253
333
  SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
254
334
  WHERE id = ${jobId} AND status = 'QUEUED'
335
+ AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= NOW())
255
336
  RETURNING id, "tenantId" AS "tenantId", "projectId" AS "projectId", type, config
256
337
  `;
257
338
  return rows[0] ?? null;
@@ -267,13 +348,20 @@ export async function claimNextQueuedJob(): Promise<ClaimedJob | null> {
267
348
  UPDATE "PlatformJob"
268
349
  SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
269
350
  WHERE id = (
270
- SELECT id FROM "PlatformJob"
351
+ SELECT id FROM "PlatformJob" p
271
352
  WHERE status = 'QUEUED'
353
+ AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= NOW())
272
354
  AND (
273
355
  type != 'DISCOVER'
274
356
  OR (SELECT COUNT(*) FROM "PlatformJob" WHERE status = 'RUNNING' AND type = 'DISCOVER') < ${maxDiscover}
275
357
  )
276
- ORDER BY "queuedAt" ASC
358
+ AND (
359
+ SELECT COUNT(*) FROM "PlatformJob" p2
360
+ WHERE p2.status = 'RUNNING'
361
+ AND p2."tenantId" = p."tenantId"
362
+ AND p2.inline = false
363
+ ) < ${MAX_CONCURRENT_PER_TENANT}
364
+ ORDER BY p."priority" DESC, p."queuedAt" ASC
277
365
  FOR UPDATE SKIP LOCKED
278
366
  LIMIT 1
279
367
  )
@@ -376,36 +464,77 @@ export async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
376
464
  stopTimer();
377
465
  crawlJobsTotal.inc({ type: job.type, status: 'failed' });
378
466
  logger.error({ jobId: job.id, type: job.type, err: String(err?.message ?? err) }, '[job-worker] job threw uncaught error');
467
+ const errorMessage = String(err?.message ?? err);
468
+ const failureType = classifyFailure(err);
469
+ // Read current retryCount before incrementing so computeNextRetryAt uses the right base.
470
+ const jobRow = await prismaAdmin.platformJob.findUnique({ where: { id: job.id }, select: { retryCount: true, result: true } }).catch(() => null);
471
+ const currentRetry = jobRow?.retryCount ?? 0;
472
+ const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
379
473
  await prismaAdmin.platformJob.update({
380
474
  where: { id: job.id },
381
- data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) },
475
+ data: {
476
+ status: nextRetryAt ? 'RETRY_WAIT' : 'FAILED',
477
+ error: errorMessage,
478
+ finishedAt: new Date(),
479
+ heartbeatAt: null,
480
+ workerId: null,
481
+ nextRetryAt,
482
+ retryCount: { increment: 1 },
483
+ failureType,
484
+ // Store failureType in result JSON for observability (backward compat)
485
+ result: { ...(jobRow?.result as any ?? {}), failureType } as any,
486
+ },
382
487
  }).catch(() => { });
488
+ // If fatal or exceeded retries — promote to DLQ immediately
489
+ if (!nextRetryAt || failureType === FailureType.FATAL) {
490
+ await prismaAdmin.platformJob.update({
491
+ where: { id: job.id },
492
+ data: { dlq: true, dlqAt: new Date(), dlqReason: errorMessage },
493
+ }).catch(() => { });
494
+ }
383
495
  }
384
496
  }
385
497
 
386
498
  let workerLoopTimer: ReturnType<typeof setInterval> | null = null;
499
+ // GAP-12: signals the semaphore-based poll loop to exit gracefully.
500
+ let workerShuttingDown = false;
387
501
 
388
- /** Re-promotes FAILED jobs to QUEUED if they are eligible for retry based on per-type limits. */
502
+ /** Re-promotes FAILED jobs to RETRY_WAIT if they are eligible for retry based on per-type limits.
503
+ * RETRY_WAIT makes the waiting period visible in the UI. A separate sweep then promotes
504
+ * RETRY_WAIT → QUEUED once nextRetryAt <= NOW(). */
389
505
  export async function retryEligibleFailedJobs(): Promise<void> {
506
+ // Legacy path: jobs that failed before failure-type classification was added.
507
+ // New failures go FAILED→RETRY_WAIT directly in the job error handler (dispatchClaimedJob).
508
+ // This only picks up old FAILED rows that have no nextRetryAt set yet.
509
+ // Phase 1: FAILED → RETRY_WAIT (GAP-07: visible waiting state)
390
510
  const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
391
511
  UPDATE "PlatformJob"
392
- SET status = 'QUEUED',
512
+ SET status = 'RETRY_WAIT',
393
513
  "retryCount" = "retryCount" + 1,
394
514
  "nextRetryAt" = NOW() + (INTERVAL '1 minute' * POWER(2, "retryCount")),
395
515
  "workerId" = NULL
396
516
  WHERE status = 'FAILED'
397
517
  AND dlq = false
398
- AND "nextRetryAt" <= NOW()
399
- AND "retryCount" < CASE
400
- WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
401
- WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
402
- WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
403
- ELSE 1
404
- END
518
+ AND "nextRetryAt" IS NULL
519
+ AND "retryCount" < 5
405
520
  RETURNING id
406
521
  `;
407
522
  if (rows.length > 0) {
408
- logger.info({ jobIds: rows.map((r: any) => r.id) }, '[job-worker] requeued failed job(s) for retry');
523
+ logger.info({ jobIds: rows.map((r: any) => r.id) }, '[job-worker] moved failed job(s) to RETRY_WAIT');
524
+ }
525
+
526
+ // Phase 2: RETRY_WAIT → QUEUED when the wait period has elapsed (GAP-07)
527
+ const readyRows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
528
+ UPDATE "PlatformJob"
529
+ SET status = 'QUEUED',
530
+ "requeuedAt" = NOW()
531
+ WHERE status = 'RETRY_WAIT'
532
+ AND "nextRetryAt" <= NOW()
533
+ AND dlq = false
534
+ RETURNING id
535
+ `;
536
+ if (readyRows.length > 0) {
537
+ logger.info({ jobIds: readyRows.map((r: any) => r.id) }, '[job-worker] promoted RETRY_WAIT job(s) to QUEUED');
409
538
  }
410
539
  }
411
540
 
@@ -414,6 +543,7 @@ export async function promoteToDeadLetterQueue(): Promise<void> {
414
543
  const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
415
544
  UPDATE "PlatformJob"
416
545
  SET dlq = true,
546
+ "dlqAt" = NOW(),
417
547
  "dlqReason" = error
418
548
  WHERE status = 'FAILED'
419
549
  AND dlq = false
@@ -444,7 +574,8 @@ export function startJobWorkerLoop(opts?: { pollIntervalMs?: number; concurrency
444
574
  const concurrency = opts?.concurrency ?? WORKER_CONCURRENCY;
445
575
  logger.info({ workerId: WORKER_ID, pollIntervalMs, concurrency }, '[job-worker] starting');
446
576
 
447
- const tick = async () => {
577
+ // GAP-12: maintenance sweep stays on a fixed interval (unchanged).
578
+ const sweepTick = async () => {
448
579
  try {
449
580
  await retryEligibleFailedJobs();
450
581
  await promoteToDeadLetterQueue();
@@ -452,33 +583,71 @@ export function startJobWorkerLoop(opts?: { pollIntervalMs?: number; concurrency
452
583
  } catch (err) {
453
584
  logger.error({ err: String(err) }, '[job-worker] stale/retry job sweep failed');
454
585
  }
455
- try {
456
- const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
457
- if (pauseRow?.value === 'true') {
586
+ };
587
+ workerLoopTimer = setInterval(sweepTick, pollIntervalMs);
588
+
589
+ // GAP-12: single continuous poll loop — checks the semaphore before every
590
+ // claim so activeExecutions never exceeds WORKER_CONCURRENCY regardless of
591
+ // how long individual jobs take. Jobs are non-blocking: withSemaphore(...)
592
+ // is NOT awaited here, so the loop immediately loops back and can claim the
593
+ // next job while previous ones are still running.
594
+ //
595
+ // NOTE (GAP-20): requeueOrphanedJobs() is intentionally NOT called at startup here.
596
+ // Calling it at startup is unsafe in multi-replica deployments — replica A would steal
597
+ // RUNNING jobs from replica B (which is still alive and processing them), causing
598
+ // double-execution. The stale-job sweep (requeueStaleRunningJobs) handles recovery
599
+ // safely via heartbeat timeout. requeueOrphanedJobs() remains available for admin use.
600
+ workerShuttingDown = false;
601
+
602
+ const pollLoop = async () => {
603
+ void sweepTick(); // run one sweep immediately on startup
604
+ while (!workerShuttingDown) {
605
+ if (activeExecutions >= concurrency) {
606
+ // All slots busy — wait before checking again without hammering the DB.
607
+ await new Promise(resolve => setTimeout(resolve, 500));
608
+ continue;
609
+ }
610
+
611
+ let paused = false;
612
+ try {
613
+ const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
614
+ paused = pauseRow?.value === 'true';
615
+ } catch {
616
+ // ignore — treat as unpaused
617
+ }
618
+
619
+ if (paused) {
458
620
  logger.info('[job-worker] queue is paused — skipping claim');
459
- } else {
460
- for (let i = 0; i < concurrency; i++) {
461
- const job = await claimNextQueuedJob();
462
- if (!job) break;
463
- void dispatchClaimedJob(job); // not awaited — runs concurrently while the loop keeps polling
464
- }
621
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
622
+ continue;
465
623
  }
466
- } catch (err) {
467
- logger.error({ err: String(err) }, '[job-worker] claim loop failed');
624
+
625
+ let job: ClaimedJob | null = null;
626
+ try {
627
+ job = await claimNextQueuedJob();
628
+ } catch (err) {
629
+ logger.error({ err: String(err) }, '[job-worker] claim failed');
630
+ }
631
+
632
+ if (!job) {
633
+ // Nothing queued — back off before polling again.
634
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
635
+ continue;
636
+ }
637
+
638
+ // Non-blocking: fire and forget into semaphore — poll loop continues immediately.
639
+ withSemaphore(() => dispatchClaimedJob(job!)).catch(err => {
640
+ logger.error({ err, jobId: job!.id }, '[worker] unhandled job error');
641
+ });
468
642
  }
469
643
  };
470
644
 
471
- workerLoopTimer = setInterval(tick, pollIntervalMs);
472
- // On startup: immediately requeue jobs orphaned by the previous process instance.
473
- // This gives instant recovery rather than waiting STALE_JOB_TIMEOUT_MINUTES (30 min).
474
- requeueOrphanedJobs().catch((err) =>
475
- logger.error({ err: String(err) }, '[job-worker] startup orphan sweep failed')
476
- );
477
- void tick();
645
+ void pollLoop();
478
646
  }
479
647
 
480
648
  /** Stops the poll loop. Mainly for tests. */
481
649
  export function stopJobWorkerLoop(): void {
650
+ workerShuttingDown = true; // GAP-12: signal continuous poll loop to exit
482
651
  if (workerLoopTimer) clearInterval(workerLoopTimer);
483
652
  workerLoopTimer = null;
484
653
  }
@@ -491,31 +660,50 @@ export async function createTrackedJob(
491
660
  const name = makeJobName(type);
492
661
  const job = await withTenant(config.tenantId, (tx) =>
493
662
  tx.platformJob.create({
494
- data: { tenantId: config.tenantId, projectId: config.projectId, type, name, status: 'RUNNING', startedAt: new Date(), config: config as any },
663
+ data: { tenantId: config.tenantId, projectId: config.projectId, type, name, status: 'RUNNING', startedAt: new Date(), inline: true, config: config as any },
495
664
  })
496
665
  ) as { id: string };
497
666
  return job.id;
498
667
  }
499
668
 
500
669
  /** Mark a tracked job DONE or FAILED. Safe to call in finally — swallows errors.
501
- * Gap 30: When marking FAILED, sets nextRetryAt (1 min) so the retry worker picks it up promptly. */
670
+ * GAP-14/15: Uses failure-type classification for per-type backoff on FAILED. */
502
671
  export async function finishTrackedJob(tenantId: string, jobId: string, error?: string): Promise<void> {
503
- const nextRetryAt = error ? new Date(Date.now() + 60_000) : undefined;
672
+ if (!error) {
673
+ await withTenant(tenantId, (tx) =>
674
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'DONE', finishedAt: new Date(), progress: 100 } })
675
+ ).catch(() => { });
676
+ return;
677
+ }
678
+ const failureType = classifyFailure(error);
679
+ const jobRow = await prismaAdmin.platformJob.findUnique({ where: { id: jobId }, select: { retryCount: true, result: true } }).catch(() => null);
680
+ const currentRetry = jobRow?.retryCount ?? 0;
681
+ const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
504
682
  await withTenant(tenantId, (tx) =>
505
683
  tx.platformJob.update({
506
684
  where: { id: jobId },
507
685
  data: {
508
- status: error ? 'FAILED' : 'DONE',
686
+ status: nextRetryAt ? 'RETRY_WAIT' : 'FAILED',
509
687
  finishedAt: new Date(),
510
- ...(error ? { error, nextRetryAt } : { progress: 100 }),
688
+ error,
689
+ nextRetryAt,
690
+ retryCount: { increment: 1 },
691
+ failureType,
692
+ result: { ...(jobRow?.result as any ?? {}), failureType } as any,
511
693
  },
512
694
  })
513
695
  ).catch(() => { });
696
+ if (!nextRetryAt || failureType === FailureType.FATAL) {
697
+ await prismaAdmin.platformJob.update({
698
+ where: { id: jobId },
699
+ data: { dlq: true, dlqAt: new Date(), dlqReason: error },
700
+ }).catch(() => { });
701
+ }
514
702
  }
515
703
 
516
704
  /** Enqueue a generic platform job — returns the job ID immediately. */
517
705
  export async function enqueueJob(type: string, config: any): Promise<string> {
518
- const { tenantId, projectId } = config;
706
+ const { tenantId, projectId, submittedBy } = config;
519
707
 
520
708
  const name = makeJobName(type);
521
709
  const job = await withTenant(tenantId, (tx) =>
@@ -527,6 +715,7 @@ export async function enqueueJob(type: string, config: any): Promise<string> {
527
715
  name,
528
716
  status: 'QUEUED',
529
717
  config: config as any,
718
+ ...(submittedBy ? { submittedBy } : {}),
530
719
  },
531
720
  })
532
721
  ) as { id: string };
@@ -580,7 +769,10 @@ export async function listJobs(tenantId: string, projectId: string) {
580
769
  });
581
770
  }
582
771
 
583
- /** Cancel a RUNNING or QUEUED job. Aborts the in-process crawl and marks DB CANCELLED. */
772
+ /** Cancel a RUNNING or QUEUED job.
773
+ * Sets status to CANCEL_REQUESTED (GAP-08) so the worker's abort handler can
774
+ * detect the request and set CANCELLED + finishedAt after the underlying work stops.
775
+ * The in-process AbortController is also triggered for same-process jobs. */
584
776
  export async function cancelJob(tenantId: string, jobId: string): Promise<void> {
585
777
  // Abort in-memory controller if job is running in the same process
586
778
  const controller = runningControllers.get(jobId);
@@ -591,7 +783,7 @@ export async function cancelJob(tenantId: string, jobId: string): Promise<void>
591
783
  await withTenant(tenantId, (tx) =>
592
784
  tx.platformJob.update({
593
785
  where: { id: jobId },
594
- data: { status: 'CANCELLED', finishedAt: new Date(), heartbeatAt: null, workerId: null },
786
+ data: { status: 'CANCEL_REQUESTED' },
595
787
  })
596
788
  );
597
789
  }
@@ -636,20 +828,21 @@ export async function updateJobProgress(tenantId: string, jobId: string, progres
636
828
  try {
637
829
  await withTenant(tenantId, async (tx) => {
638
830
  // DB-flag cancellation: works for both inline and SQS backends.
639
- // When a user cancels, status is set to 'CANCELLED' in DB.
831
+ // When a user cancels, status is set to CANCEL_REQUESTED (GAP-08).
640
832
  // This check fires at each progress update (every few seconds during crawl).
641
833
  const current = await tx.platformJob.findUnique({
642
834
  where: { id: jobId },
643
835
  select: { status: true },
644
836
  });
645
- if (current?.status === 'CANCELLED') {
837
+ if (current?.status === 'CANCELLED' || current?.status === 'CANCEL_REQUESTED') {
646
838
  runningControllers.get(jobId)?.abort();
647
839
  runningControllers.delete(jobId);
648
840
  return; // abort signal propagates; crawlProject exits on next signal check
649
841
  }
650
842
  await tx.platformJob.update({
651
843
  where: { id: jobId },
652
- data: { progress, result: { step } as any, heartbeatAt: new Date() },
844
+ // GAP-09: write currentStep as a proper column in addition to result.step (backward compat)
845
+ data: { progress, currentStep: step, result: { step } as any, heartbeatAt: new Date() },
653
846
  });
654
847
  });
655
848
  } catch { /* non-fatal — progress updates best-effort */ }
@@ -663,7 +856,7 @@ async function heartbeatJob(tenantId: string, jobId: string) {
663
856
  where: { id: jobId },
664
857
  select: { status: true },
665
858
  });
666
- if (current?.status === 'CANCELLED') {
859
+ if (current?.status === 'CANCELLED' || current?.status === 'CANCEL_REQUESTED') {
667
860
  runningControllers.get(jobId)?.abort();
668
861
  runningControllers.delete(jobId);
669
862
  return;
@@ -800,6 +993,19 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
800
993
  storageConfig: storageConfigForCrawl,
801
994
  onCheckpoint: async (visitedUrls, pendingQueue) => {
802
995
  jobCheckpoints.set(jobId, { visitedUrls, pendingQueue });
996
+ // GAP-19: configurable checkpoint interval; GAP-04: persist to DB for crash recovery
997
+ const CHECKPOINT_INTERVAL = parseInt(process.env.CRAWL_CHECKPOINT_INTERVAL ?? '10');
998
+ if (visitedUrls.length % CHECKPOINT_INTERVAL === 0) {
999
+ try {
1000
+ await prismaAdmin.platformJob.update({
1001
+ where: { id: jobId },
1002
+ data: {
1003
+ checkpoint: { visitedUrls, pendingQueue } as any,
1004
+ checkpointAt: new Date(),
1005
+ },
1006
+ });
1007
+ } catch { /* non-fatal */ }
1008
+ }
803
1009
  },
804
1010
  }, onProgress);
805
1011
  jobCheckpoints.delete(jobId);
@@ -1789,6 +1995,20 @@ export async function processDataRetentionPurge(): Promise<void> {
1789
1995
  },
1790
1996
  });
1791
1997
  logger.info({ deleted: sessionResult.count }, '[data-retention] AuthSession purged');
1998
+
1999
+ // PlatformJob: delete completed jobs older than 90 days (GAP-24)
2000
+ const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
2001
+ const deletedJobs = await prismaAdmin.platformJob.deleteMany({
2002
+ where: {
2003
+ status: { in: ['DONE', 'FAILED'] },
2004
+ dlq: false,
2005
+ inline: false,
2006
+ finishedAt: { lt: ninetyDaysAgo },
2007
+ },
2008
+ });
2009
+ if (deletedJobs.count > 0) {
2010
+ logger.info({ count: deletedJobs.count }, '[data-retention] deleted old completed jobs');
2011
+ }
1792
2012
  }
1793
2013
 
1794
2014
  // ── URL Discovery Job ─────────────────────────────────────────────────────────