feat(jobs): 增加后台任务运行时
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE "background_jobs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'queued',
|
||||
"dedupe_key" TEXT,
|
||||
"payload" JSONB NOT NULL DEFAULT '{}',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"max_attempts" INTEGER NOT NULL DEFAULT 3,
|
||||
"available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"locked_by" TEXT,
|
||||
"locked_until" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "background_jobs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "background_jobs_status_available_at_idx"
|
||||
ON "background_jobs"("status", "available_at" ASC, "created_at" ASC);
|
||||
|
||||
CREATE INDEX "background_jobs_type_status_available_at_idx"
|
||||
ON "background_jobs"("type", "status", "available_at" ASC);
|
||||
|
||||
CREATE INDEX "background_jobs_lease_idx"
|
||||
ON "background_jobs"("status", "locked_until" ASC)
|
||||
WHERE "locked_until" IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "background_jobs_active_dedupe_key_idx"
|
||||
ON "background_jobs"("type", "dedupe_key")
|
||||
WHERE "dedupe_key" IS NOT NULL AND "status" IN ('queued', 'running');
|
||||
@@ -423,6 +423,25 @@ model AiLog {
|
||||
@@map("ai_logs")
|
||||
}
|
||||
|
||||
model BackgroundJob {
|
||||
id String @id @default(cuid())
|
||||
type String
|
||||
status String @default("queued")
|
||||
dedupeKey String? @map("dedupe_key")
|
||||
payload Json @default("{}")
|
||||
attempts Int @default(0)
|
||||
maxAttempts Int @default(3) @map("max_attempts")
|
||||
availableAt DateTime @default(now()) @map("available_at")
|
||||
lockedBy String? @map("locked_by")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastError String? @map("last_error")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("background_jobs")
|
||||
}
|
||||
|
||||
model XiaobaoRiskSummary {
|
||||
versionId String @id @map("version_id")
|
||||
riskLevel String @map("risk_level")
|
||||
|
||||
@@ -20,6 +20,7 @@ import { DataModule } from './modules/data/data.module';
|
||||
import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { JobsModule } from './modules/jobs/jobs.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -41,6 +42,7 @@ import { HealthModule } from './modules/health/health.module';
|
||||
MigrationModule,
|
||||
V22QueryModule,
|
||||
HealthModule,
|
||||
JobsModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [],
|
||||
|
||||
48
apps/server/src/modules/jobs/background-job.worker.spec.ts
Normal file
48
apps/server/src/modules/jobs/background-job.worker.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { BackgroundJobWorker } from './background-job.worker';
|
||||
|
||||
describe('BackgroundJobWorker', () => {
|
||||
it('runs a registered handler and marks the job succeeded', async () => {
|
||||
const lock = {
|
||||
claimNext: jest.fn().mockResolvedValue({
|
||||
id: 'job-1',
|
||||
type: 'demo.job',
|
||||
payload: { ok: true },
|
||||
}),
|
||||
};
|
||||
const jobs = {
|
||||
markSucceeded: jest.fn(),
|
||||
markFailed: jest.fn(),
|
||||
};
|
||||
const worker = new BackgroundJobWorker(lock as any, jobs as any);
|
||||
const handler = jest.fn().mockResolvedValue(undefined);
|
||||
worker.registerHandler('demo.job', handler);
|
||||
|
||||
await expect(worker.runOnce({ workerId: 'worker-a' })).resolves.toBe(true);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ ok: true }, expect.objectContaining({ id: 'job-1' }));
|
||||
expect(jobs.markSucceeded).toHaveBeenCalledWith('job-1');
|
||||
expect(jobs.markFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks a job failed when the handler throws', async () => {
|
||||
const lock = {
|
||||
claimNext: jest.fn().mockResolvedValue({
|
||||
id: 'job-1',
|
||||
type: 'demo.job',
|
||||
payload: {},
|
||||
}),
|
||||
};
|
||||
const jobs = {
|
||||
markSucceeded: jest.fn(),
|
||||
markFailed: jest.fn(),
|
||||
};
|
||||
const worker = new BackgroundJobWorker(lock as any, jobs as any);
|
||||
const error = new Error('bad handler');
|
||||
worker.registerHandler('demo.job', jest.fn().mockRejectedValue(error));
|
||||
|
||||
await expect(worker.runOnce({ workerId: 'worker-a' })).resolves.toBe(false);
|
||||
|
||||
expect(jobs.markFailed).toHaveBeenCalledWith('job-1', error, expect.any(Object));
|
||||
expect(jobs.markSucceeded).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
58
apps/server/src/modules/jobs/background-job.worker.ts
Normal file
58
apps/server/src/modules/jobs/background-job.worker.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JobLockService } from './job-lock.service';
|
||||
import { JobsService } from './jobs.service';
|
||||
import { BackgroundJobRecord } from './jobs.types';
|
||||
|
||||
export type BackgroundJobHandler = (payload: unknown, job: BackgroundJobRecord) => Promise<void> | void;
|
||||
|
||||
export interface RunJobOnceOptions {
|
||||
workerId: string;
|
||||
now?: Date;
|
||||
leaseMs?: number;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BackgroundJobWorker {
|
||||
private readonly handlers = new Map<string, BackgroundJobHandler>();
|
||||
|
||||
constructor(
|
||||
private readonly locks: JobLockService,
|
||||
private readonly jobs: JobsService,
|
||||
) {}
|
||||
|
||||
registerHandler(type: string, handler: BackgroundJobHandler) {
|
||||
this.handlers.set(type, handler);
|
||||
}
|
||||
|
||||
async runOnce(options: RunJobOnceOptions): Promise<boolean> {
|
||||
const job = await this.locks.claimNext({
|
||||
workerId: options.workerId,
|
||||
types: Array.from(this.handlers.keys()),
|
||||
now: options.now,
|
||||
leaseMs: options.leaseMs,
|
||||
});
|
||||
if (!job) return false;
|
||||
|
||||
const handler = this.handlers.get(job.type);
|
||||
if (!handler) {
|
||||
await this.jobs.markFailed(job.id, `No handler registered for ${job.type}`, {
|
||||
now: options.now,
|
||||
retryDelayMs: options.retryDelayMs,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await handler(job.payload, job);
|
||||
await this.jobs.markSucceeded(job.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
await this.jobs.markFailed(job.id, error, {
|
||||
now: options.now,
|
||||
retryDelayMs: options.retryDelayMs,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
apps/server/src/modules/jobs/job-lock.service.spec.ts
Normal file
51
apps/server/src/modules/jobs/job-lock.service.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { JobLockService } from './job-lock.service';
|
||||
|
||||
describe('JobLockService', () => {
|
||||
it('claims queued or expired jobs with a lease and increments attempts', async () => {
|
||||
const tx = {
|
||||
$queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 'job-1' }]),
|
||||
backgroundJob: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'job-1', status: 'running' }),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: jest.fn((callback) => callback(tx)),
|
||||
};
|
||||
const service = new JobLockService(prisma as any);
|
||||
const now = new Date('2026-07-08T10:00:00.000Z');
|
||||
|
||||
const result = await service.claimNext({
|
||||
workerId: 'worker-a',
|
||||
types: ['xiaobao.summary.refresh'],
|
||||
now,
|
||||
leaseMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: 'job-1', status: 'running' });
|
||||
expect(tx.$queryRawUnsafe.mock.calls[0][0]).toContain('FOR UPDATE SKIP LOCKED');
|
||||
expect(tx.backgroundJob.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-1' },
|
||||
data: {
|
||||
status: 'running',
|
||||
lockedBy: 'worker-a',
|
||||
lockedUntil: new Date('2026-07-08T10:01:00.000Z'),
|
||||
attempts: { increment: 1 },
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when no claimable jobs exist', async () => {
|
||||
const tx = {
|
||||
$queryRawUnsafe: jest.fn().mockResolvedValue([]),
|
||||
backgroundJob: { update: jest.fn() },
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: jest.fn((callback) => callback(tx)),
|
||||
};
|
||||
const service = new JobLockService(prisma as any);
|
||||
|
||||
await expect(service.claimNext({ workerId: 'worker-a' })).resolves.toBeNull();
|
||||
expect(tx.backgroundJob.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
57
apps/server/src/modules/jobs/job-lock.service.ts
Normal file
57
apps/server/src/modules/jobs/job-lock.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { BackgroundJobRecord, ClaimJobInput, JOBS_PRISMA } from './jobs.types';
|
||||
|
||||
const DEFAULT_LEASE_MS = 60_000;
|
||||
|
||||
@Injectable()
|
||||
export class JobLockService {
|
||||
constructor(@Inject(JOBS_PRISMA) private readonly prisma: any) {}
|
||||
|
||||
async claimNext(input: ClaimJobInput): Promise<BackgroundJobRecord | null> {
|
||||
const now = input.now ?? new Date();
|
||||
const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS;
|
||||
const types = input.types?.map((type) => type.trim()).filter(Boolean) ?? [];
|
||||
|
||||
return (this.prisma as any).$transaction(async (tx: any) => {
|
||||
const params: unknown[] = [now];
|
||||
const typeCondition = buildTypeCondition(types, params);
|
||||
const rows = await tx.$queryRawUnsafe(
|
||||
`
|
||||
SELECT id
|
||||
FROM background_jobs
|
||||
WHERE (
|
||||
(status = 'queued' AND available_at <= $1)
|
||||
OR (status = 'running' AND locked_until IS NOT NULL AND locked_until < $1)
|
||||
)
|
||||
${typeCondition}
|
||||
ORDER BY available_at ASC, created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`,
|
||||
...params,
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) return null;
|
||||
|
||||
return tx.backgroundJob.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'running',
|
||||
lockedBy: input.workerId,
|
||||
lockedUntil: new Date(now.getTime() + leaseMs),
|
||||
attempts: { increment: 1 },
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildTypeCondition(types: string[], params: unknown[]): string {
|
||||
if (types.length === 0) return '';
|
||||
const placeholders = types.map((type) => {
|
||||
params.push(type);
|
||||
return `$${params.length}`;
|
||||
});
|
||||
return `AND type IN (${placeholders.join(', ')})`;
|
||||
}
|
||||
17
apps/server/src/modules/jobs/jobs.module.ts
Normal file
17
apps/server/src/modules/jobs/jobs.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { BackgroundJobWorker } from './background-job.worker';
|
||||
import { JobLockService } from './job-lock.service';
|
||||
import { JobsService } from './jobs.service';
|
||||
import { JOBS_PRISMA } from './jobs.types';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: JOBS_PRISMA, useExisting: PrismaService },
|
||||
JobsService,
|
||||
JobLockService,
|
||||
BackgroundJobWorker,
|
||||
],
|
||||
exports: [JobsService, JobLockService, BackgroundJobWorker],
|
||||
})
|
||||
export class JobsModule {}
|
||||
115
apps/server/src/modules/jobs/jobs.service.spec.ts
Normal file
115
apps/server/src/modules/jobs/jobs.service.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { JobsService } from './jobs.service';
|
||||
|
||||
describe('JobsService', () => {
|
||||
function makeService() {
|
||||
const prisma = {
|
||||
backgroundJob: {
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
return { prisma, service: new JobsService(prisma as any) };
|
||||
}
|
||||
|
||||
it('dedupes active jobs by dedupeKey before creating a new row', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.backgroundJob.findFirst.mockResolvedValue({ id: 'job-1', status: 'queued' });
|
||||
|
||||
const result = await service.enqueue({
|
||||
type: 'xiaobao.summary.refresh',
|
||||
dedupeKey: 'version-1',
|
||||
payload: { versionId: 'version-1' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: 'job-1', status: 'queued' });
|
||||
expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
type: 'xiaobao.summary.refresh',
|
||||
dedupeKey: 'version-1',
|
||||
status: { in: ['queued', 'running'] },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
expect(prisma.backgroundJob.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a queued job when no active dedupe match exists', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.backgroundJob.findFirst.mockResolvedValue(null);
|
||||
prisma.backgroundJob.create.mockResolvedValue({ id: 'job-2', status: 'queued' });
|
||||
|
||||
await service.enqueue({
|
||||
type: 'xiaobao.summary.refresh',
|
||||
dedupeKey: 'version-2',
|
||||
payload: { versionId: 'version-2' },
|
||||
maxAttempts: 5,
|
||||
});
|
||||
|
||||
expect(prisma.backgroundJob.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
type: 'xiaobao.summary.refresh',
|
||||
dedupeKey: 'version-2',
|
||||
payload: { versionId: 'version-2' },
|
||||
maxAttempts: 5,
|
||||
availableAt: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers from concurrent dedupe unique conflicts by returning the active job', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.backgroundJob.findFirst
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'job-existing', status: 'queued' });
|
||||
prisma.backgroundJob.create.mockRejectedValue(Object.assign(new Error('duplicate'), { code: 'P2002' }));
|
||||
|
||||
const result = await service.enqueue({
|
||||
type: 'xiaobao.summary.refresh',
|
||||
dedupeKey: 'version-2',
|
||||
payload: { versionId: 'version-2' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: 'job-existing', status: 'queued' });
|
||||
expect(prisma.backgroundJob.findFirst).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('requeues failed jobs while attempts remain', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const now = new Date('2026-07-08T10:00:00.000Z');
|
||||
prisma.backgroundJob.findUnique.mockResolvedValue({ id: 'job-1', attempts: 1, maxAttempts: 3 });
|
||||
|
||||
await service.markFailed('job-1', new Error('boom'), { now, retryDelayMs: 30_000 });
|
||||
|
||||
expect(prisma.backgroundJob.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-1' },
|
||||
data: {
|
||||
status: 'queued',
|
||||
availableAt: new Date('2026-07-08T10:00:30.000Z'),
|
||||
lockedBy: null,
|
||||
lockedUntil: null,
|
||||
lastError: 'boom',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('marks failed jobs terminal when max attempts is reached', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const now = new Date('2026-07-08T10:00:00.000Z');
|
||||
prisma.backgroundJob.findUnique.mockResolvedValue({ id: 'job-1', attempts: 3, maxAttempts: 3 });
|
||||
|
||||
await service.markFailed('job-1', 'still broken', { now, retryDelayMs: 30_000 });
|
||||
|
||||
expect(prisma.backgroundJob.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-1' },
|
||||
data: {
|
||||
status: 'failed',
|
||||
availableAt: now,
|
||||
lockedBy: null,
|
||||
lockedUntil: null,
|
||||
lastError: 'still broken',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
104
apps/server/src/modules/jobs/jobs.service.ts
Normal file
104
apps/server/src/modules/jobs/jobs.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ACTIVE_JOB_STATUSES, BackgroundJobRecord, EnqueueJobInput, JOBS_PRISMA, MarkFailedOptions } from './jobs.types';
|
||||
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
const DEFAULT_RETRY_DELAY_MS = 60_000;
|
||||
const MAX_ERROR_LENGTH = 2000;
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
constructor(@Inject(JOBS_PRISMA) private readonly prisma: any) {}
|
||||
|
||||
async enqueue(input: EnqueueJobInput): Promise<BackgroundJobRecord> {
|
||||
const type = input.type.trim();
|
||||
if (!type) throw new Error('Job type is required');
|
||||
const dedupeKey = input.dedupeKey?.trim() || null;
|
||||
|
||||
if (dedupeKey) {
|
||||
const existing = await this.findActiveDedupe(type, dedupeKey);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.delegate.create({
|
||||
data: {
|
||||
type,
|
||||
dedupeKey,
|
||||
payload: input.payload ?? {},
|
||||
maxAttempts: input.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
availableAt: input.availableAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (dedupeKey && isUniqueConflict(error)) {
|
||||
const existing = await this.findActiveDedupe(type, dedupeKey);
|
||||
if (existing) return existing;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
markSucceeded(id: string, now = new Date()): Promise<BackgroundJobRecord> {
|
||||
return this.delegate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'succeeded',
|
||||
lockedBy: null,
|
||||
lockedUntil: null,
|
||||
completedAt: now,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async markFailed(id: string, error: unknown, options: MarkFailedOptions = {}): Promise<BackgroundJobRecord> {
|
||||
const job = await this.delegate.findUnique({ where: { id } });
|
||||
if (!job) throw new NotFoundException('Background job not found');
|
||||
|
||||
const now = options.now ?? new Date();
|
||||
const attempts = Number(job.attempts ?? 0);
|
||||
const maxAttempts = Number(job.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
||||
const hasRetriesLeft = attempts < maxAttempts;
|
||||
const availableAt = hasRetriesLeft
|
||||
? new Date(now.getTime() + (options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS))
|
||||
: now;
|
||||
|
||||
return this.delegate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: hasRetriesLeft ? 'queued' : 'failed',
|
||||
availableAt,
|
||||
lockedBy: null,
|
||||
lockedUntil: null,
|
||||
lastError: normalizeError(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private get delegate(): any {
|
||||
return (this.prisma as any).backgroundJob;
|
||||
}
|
||||
|
||||
private findActiveDedupe(type: string, dedupeKey: string): Promise<BackgroundJobRecord | null> {
|
||||
return this.delegate.findFirst({
|
||||
where: {
|
||||
type,
|
||||
dedupeKey,
|
||||
status: { in: [...ACTIVE_JOB_STATUSES] },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.slice(0, MAX_ERROR_LENGTH);
|
||||
}
|
||||
|
||||
function isUniqueConflict(error: unknown): boolean {
|
||||
return typeof error === 'object'
|
||||
&& error !== null
|
||||
&& 'code' in error
|
||||
&& (error as { code?: unknown }).code === 'P2002';
|
||||
}
|
||||
38
apps/server/src/modules/jobs/jobs.types.ts
Normal file
38
apps/server/src/modules/jobs/jobs.types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const ACTIVE_JOB_STATUSES = ['queued', 'running'] as const;
|
||||
export const JOBS_PRISMA = 'JOBS_PRISMA';
|
||||
|
||||
export type BackgroundJobStatus = 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
export interface BackgroundJobRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
status: BackgroundJobStatus;
|
||||
dedupeKey?: string | null;
|
||||
payload: unknown;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
availableAt?: Date;
|
||||
lockedBy?: string | null;
|
||||
lockedUntil?: Date | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
export interface EnqueueJobInput {
|
||||
type: string;
|
||||
dedupeKey?: string | null;
|
||||
payload?: unknown;
|
||||
maxAttempts?: number;
|
||||
availableAt?: Date;
|
||||
}
|
||||
|
||||
export interface ClaimJobInput {
|
||||
workerId: string;
|
||||
types?: string[];
|
||||
now?: Date;
|
||||
leaseMs?: number;
|
||||
}
|
||||
|
||||
export interface MarkFailedOptions {
|
||||
now?: Date;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user