forked from ChaiHao/BPOS-MVP
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
84 lines
3.8 KiB
TypeScript
84 lines
3.8 KiB
TypeScript
/**
|
|
* 投递五诺的纯判定部分 —— 零 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,
|
|
};
|
|
}
|