import { Injectable, Logger } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../database/prisma.service"; @Injectable() export class TaskLockService { private readonly logger = new Logger(TaskLockService.name); private readonly activeTasks = new Set(); constructor(private readonly prisma: PrismaService) {} async runWithLease( key: string, ttlMs: number, task: () => Promise, logger: Logger, ): Promise { if (this.activeTasks.has(key)) { logger.warn(`Skipping ${key}: task is already running in this process`); return null; } const owner = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const acquired = await this.acquireLease(key, owner, ttlMs); if (!acquired) { logger.warn(`Skipping ${key}: lease is already held by another instance`); return null; } this.activeTasks.add(key); try { return await task(); } finally { this.activeTasks.delete(key); await this.releaseLease(key, owner); } } private async acquireLease( key: string, owner: string, ttlMs: number, ): Promise { const rows = await this.prisma.$queryRaw<{ key: string }[]>( Prisma.sql` INSERT INTO app_settings (key, value, updated_at) VALUES (${this.getDbKey(key)}, ${owner}, NOW() + (${ttlMs} * INTERVAL '1 millisecond')) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at WHERE app_settings.updated_at < NOW() OR app_settings.value = ${owner} RETURNING key `, ); return rows.length > 0; } private async releaseLease(key: string, owner: string): Promise { try { await this.prisma.$executeRaw( Prisma.sql` DELETE FROM app_settings WHERE key = ${this.getDbKey(key)} AND value = ${owner} `, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.logger.warn(`Failed to release task lease ${key}: ${message}`); } } private getDbKey(key: string): string { return `task_lock:${key}`; } }