feat(jobs): 增加后台任务运行时

This commit is contained in:
2026-07-08 17:02:51 +08:00
parent 69f50ea1f5
commit ba999c9322
14 changed files with 575 additions and 2 deletions

View File

@@ -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');

View File

@@ -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")

View File

@@ -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: [],

View 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();
});
});

View 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;
}
}
}

View 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();
});
});

View 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(', ')})`;
}

View 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 {}

View 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',
},
});
});
});

View 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';
}

View 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;
}