/** * 投递五诺的纯判定部分 —— 零 IO(编码规范 §5.3;语义出处 OS 6.3 / §7.1)。 * * 先持久后投递 —— 信封写存储与入队**同一事务**(adapter 承担) * 同任务按序 —— 任务级串行消费(adapter 以 advisory lock 承担) * 至少一次+幂等 —— 消费侧幂等表(事件 ID × 消费者)唯一约束 + 本文件的去重判定 * 处理有回执 —— 失败重投,超限入死信(本文件给出「该不该再投」) * 扇出与幂等 —— 同一事件命中多任务即多份独立投递,各带投递 ID; * 同一事件对同一触发器**至多起一单**,以事件 ID 为幂等键 */ export type RetryPolicy = { readonly maxAttempts: number; readonly backoffMs: readonly number[]; }; export const DEFAULT_RETRY: RetryPolicy = { maxAttempts: 5, backoffMs: [1_000, 5_000, 30_000, 120_000] }; export type RetryDecision = | { readonly kind: 'retry'; readonly delayMs: number; readonly attempt: number } | { readonly kind: 'dead-letter'; readonly attempts: number }; export function decideRetry(attempt: number, policy: RetryPolicy = DEFAULT_RETRY): RetryDecision { if (attempt >= policy.maxAttempts) return { kind: 'dead-letter', attempts: attempt }; const idx = Math.min(attempt - 1, policy.backoffMs.length - 1); return { kind: 'retry', delayMs: policy.backoffMs[Math.max(idx, 0)] ?? 1_000, attempt: attempt + 1 }; } /** 幂等键:事件 ID × 消费者 —— 消费侧唯一约束的形态 */ export function consumptionKey(eventId: string, consumer: string): string { return `${eventId}::${consumer}`; } /** * 扇出:同一事件命中多个任务监听 → 每个命中任务一份独立投递(信封关联保持单目标)。 * 同一任务命中多条监听只产生**一份**投递 —— 任务侧按事件 ID 幂等,重复投递无意义。 */ export function fanOutTargets(hits: ReadonlyArray<{ taskId: string }>): string[] { return [...new Set(hits.map((h) => h.taskId))]; } /** * 三级消费的裁定(OS 6.3): * 任务监听与触发器**相互独立**判定 —— 同一事件可既推进旧单又起新单; * 消息兜底**只在**既无监听命中亦无触发命中时发生。 * * 注:有主 ≠ 必须唤醒 —— 归属解析命中的任务,事件一律写入其任务数据与任务记忆(记录); * 是否推进由监听命中决定;零命中时仅记录,**不降级为无主、不入消息兜底**。 */ export type ConsumptionPlan = { readonly taskTargets: readonly string[]; readonly triggerTargets: readonly string[]; readonly messageFallback: boolean; readonly recordOnlyTask: string | undefined; }; export function planConsumption(input: { readonly listenerHits: ReadonlyArray<{ taskId: string }>; readonly triggerHits: readonly string[]; readonly ownedTaskId: string | undefined; /** * 信封是否**有主** —— 关联里指了目标就算有主,不限于任务。 * * 不能拿「有没有 ownedTaskId」当有主:引擎自己发的工作流事件 * (`target: { kind: 'workflow' }`)照样是有主的,只是主不是任务。 * 把它们判成无主,等于把每次起单都当成「没人认领的入站件」塞进别人收件箱。 */ readonly hasOwner: boolean; }): ConsumptionPlan { const taskTargets = fanOutTargets(input.listenerHits); const triggerTargets = [...new Set(input.triggerHits)]; const nothingHit = taskTargets.length === 0 && triggerTargets.length === 0; return { taskTargets, triggerTargets, // 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主**的件(OS 6.3) messageFallback: nothingHit && !input.hasOwner, recordOnlyTask: input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId) ? input.ownedTaskId : undefined, }; }