feat(jobs): 增加后台任务运行时
This commit is contained in:
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';
|
||||
}
|
||||
Reference in New Issue
Block a user