/** * 任务推进的**跨进程租约** —— 一张单同一时刻只许一个后端在推。 * * `task-lock.ts` 那把是**进程内**的互斥,它开头就写着:「MVP 单进程,进程内互斥就够; * 多进程部署时再叠一层」。2026-08-24 那一天就撞上了:本机与局域网另一台机器上各跑着 * 一个后端,连的是同一个库,两边的推进器各自 2 秒扫一次「running 的单」, * **同时推同一张**。两拍并发跑在同一份 `loop_state` 上,后写的整份覆盖先写的 —— * 「给 S2 发过卡」这个事实被抹掉、S1 被收了两遍,随后 finish 与重发两道门对着撞, * 连拒五拍熔断(柴浩那一单)。 * * 为什么不用 `pg_try_advisory_lock`:它是**会话级**的,而我们经连接池取连接 —— * 加锁与解锁多半落在不同连接上。`pg_try_advisory_xact_lock` 是事务级的,而推一拍 * 要调模型、要写好几张表,不该整个包在一个事务里。 * * 所以用**库里的一列**当租约:抢到才推,推完就还;**带过期时间** —— 进程崩了不锁死, * 租约一过别的进程照样接得上。这是任务队列的老办法,跨进程、跨机器都算数。 */ import { sql } from 'drizzle-orm'; import type { Db } from '@shared/db.ts'; /** * 租约多久过期。推一拍最慢是一次模型调用(实测 1.5-5 秒,关思考之前有过 56 秒), * 而推进器一轮最多连推 3 拍 —— 给到 5 分钟:比最慢的一轮宽得多,又不至于 * 让崩掉的进程把一张单锁上半天。 */ export const LEASE_MS = 5 * 60 * 1000; function rowsOf(result: unknown): unknown[] { if (Array.isArray(result)) return result; const rows = (result as { rows?: unknown }).rows; return Array.isArray(rows) ? rows : []; } /** * 抢这一张单的推进权。**抢到返回 true**,没抢到(别的进程正推着)返回 false。 * * 一条 SQL 里判 + 占:`where advancing_until is null or advancing_until < now()` —— * 两个进程同时来,PG 的行锁保证只有一个的 UPDATE 命中,另一个 RETURNING 空。 * 判和占分两句写是经典的竞态,这里不给它机会。 */ export async function claimTask(db: Db, taskId: string, leaseMs: number = LEASE_MS): Promise { const result = await db.execute(sql` update sys.task set advancing_until = now() + (${leaseMs} || ' milliseconds')::interval where id = ${taskId} and (advancing_until is null or advancing_until < now()) returning id`); return rowsOf(result).length > 0; } /** * 推完就还 —— 不还也不会锁死(租约会过期),但还了下一轮就能接着推, * 不必干等到过期。**失败不抛**:还锁失败不该把这一拍的成果连累掉。 */ export async function releaseTask(db: Db, taskId: string): Promise { await db .execute(sql`update sys.task set advancing_until = null where id = ${taskId}`) .catch(() => undefined); }