@detiq/crawler 1.1.24 → 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.
- package/package.json +1 -1
- package/src/job-queue.ts +264 -44
package/package.json
CHANGED
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
|
-
|
|
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: {
|
|
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
|
|
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 = '
|
|
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"
|
|
399
|
-
AND "retryCount" <
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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:
|
|
686
|
+
status: nextRetryAt ? 'RETRY_WAIT' : 'FAILED',
|
|
509
687
|
finishedAt: new Date(),
|
|
510
|
-
|
|
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.
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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 ─────────────────────────────────────────────────────────
|