/** * 从 ledger 派生执行顺序的调度器:ready FIFO+queued 去重、依赖索引、canvas claims 与重试 min-heap。 * 所有权威变化通过 options.mutate 写回 Root;队列本身既不是历史,也不保存另一套任务状态。 * 发送前等待宿主持久化精确 body/key 和 dispatch 标记,结果协调及持久化成功后才释放占用。 * claims/holds 只约束本 scheduler;跨标签、进程或服务端的 writer/身份隔离由 engine 与 storage 端口负责。 */ import type { Id, Json, OperationIntent } from '../canvas-core/types.ts'; import { fault, readMap } from '../canvas-core/values.ts'; import { createLedger, type FrozenRequest, type LedgerSnapshot, type Outcome } from './ledger.ts'; import { createDeque, createMinHeap } from './structures.ts'; /** 端口解释后的执行状态;blocked 表示需显式恢复,canonical 是尚待协调校验的回执数据。 */ export type OperationStatus = { readonly outcome: Outcome; readonly error?: string; readonly retryAfterMs?: number; readonly canonical?: Json; readonly blocked?: boolean }; /** 当前可变意图编译出的 wire 计划,交 ledger.freeze 捕获后才成为不可更改的 FrozenRequest。 */ export type CompiledRequest = { readonly kind?: FrozenRequest['kind']; readonly ownerCanvasId?: Id; readonly runId?: Id; readonly protocolVersion?: number; readonly payload: Json; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly> }; /** 可注入时钟和 timer 端口,便于确定性测试重试/时钟变化;默认 now 使用 Date.now。 */ export type Clock = { now(): number; setTimer(callback: () => void, delay: number): unknown; clearTimer(handle: unknown): void }; /** 默认平台计时适配,定时器只负责唤醒,pump 仍检查当前账本和到期时间。 */ const browserClock: Clock = { now: () => Date.now(), setTimer: (fn, delay) => setTimeout(fn, delay), clearTimer: handle => clearTimeout(handle as ReturnType) }; /** mutate 回调内使用的临时可变账本接口,不直接暴露其内部表。 */ type Ledger = ReturnType; /** engine 提供准入、纯编译、原子账本更新、持久屏障、外部端口和完成协调。 * complete 必须连回执/基线/working/ledger 一起处理,不允许仅把请求先标成功。 */ export type SchedulerOptions = { /** false 进入准入等待集合,由 refreshAdmission 唤醒;不在此把逻辑操作改为 blocked。 */ admit?: (intent:OperationIntent)=>boolean; /** 每次准入/唤醒读取当前权威快照,不信任入队时捕获的旧状态。 */ getLedger(): LedgerSnapshot; /** 在 engine 的受控发布路径内修改账本,失败不能留下半份持久事实。 */ mutate(reduce: (ledger: Ledger) => void): void; /** 等待当前 Root 可靠存储;失败保持请求身份与占用,不退回生成新请求。 * scheduler 信任此 Promise 的持久承诺,本身不核对 durableGeneration/底层事务。 */ persist(): Promise; /** 为新冻结请求分配 ID;恢复/重试沿用原 ID,不再次调用此工厂。 */ createId(): Id; /** 从当前意图推导需要 claim 的 canvas;编译结果必须覆盖同一集合。 */ participants(intent: OperationIntent): readonly Id[]; /** 同步编译一个意图;不在这里发送,payload/版本随后由 ledger.freeze 捕获。 */ compile(intent: OperationIntent, requestId: Id): CompiledRequest; /** 将选中的连续候选缩为非空前缀,返回值必须是 1..intents.length 的整数。 */ batchLimit?: (intents: readonly OperationIntent[]) => number; /** 可选抵消协调钩子:先实际标记 ledger,再返回已抵消的候选前缀 ID;调度器不自行构造证明。 */ elide?: (intents: readonly OperationIntent[]) => readonly Id[]; /** 多意图编译器仅在最终候选数 >1 时使用,单项仍走 compile。 */ compileBatch?: (intents: readonly OperationIntent[], requestId: Id) => CompiledRequest; /** 使用保存的冻结请求发写端口;端口负责协议/权限与回执解释,不得重编 payload。 */ submit(request: FrozenRequest, signal: AbortSignal): Promise; /** 按原请求身份查结果,端口解释回执;端口抛错时 scheduler 统一记为 unresolved/transport_unknown。 */ query(request: FrozenRequest, signal: AbortSignal): Promise; /** 恢复时确认冻结格式是否有解释器;省略钩子只默认支持 protocolVersion=1。 */ supportsRequest?: (request: FrozenRequest) => boolean; /** 同步协调回执/base/working 与 ledger 的宿主边界;不得返回异步准备结果。 */ complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void; /** 报告存储/协调/编译错误,scheduler 隔离此回调抛错,不能用它改写请求结果。 */ onError(error: unknown): void; clock?: Clock; concurrency?: number; /** 按 submit attempt 提供有限退避毫秒;正常值取至少 1ms,端口 retryAfterMs 可覆盖此次值。 */ retryDelay?: (attempt: number) => number; }; /** Queue/heap/claims are indexes only. Every authoritative transition lives in the ledger. */ /** 创建暂停态 scheduler;start 先恢复完整 claim 集,再允许执行任何新工作。 */ export function createScheduler(options: SchedulerOptions) { const clock = options.clock ?? browserClock, concurrency = options.concurrency ?? 2; if (!Number.isSafeInteger(concurrency) || concurrency < 1) throw fault('invalid_concurrency'); // 准入门禁等待者不进入忙轮询;加载/授权等条件变化时由 refreshAdmission 显式唤醒。 const admissionWaiters=new Set(); // FIFO 保存待处理逻辑 ID,queued 防重复;claims 是 canvas→request,多个参与者一次认领。 const ready = createDeque(), queued = new Set(), claims = new Map(); // waiters 按被占 canvas 等待,dependents 按逻辑依赖等待,ordered 保留全局序列以挑连续批。 const waiters = new Map>(), dependents = new Map>(), indexed = new Set(); // 最早 dueAt 优先;同到期按入堆 sequence 稳定排序,token 让旧 timer/旧重试条目惰性失效。 const retry = createMinHeap<{ id: Id; dueAt: number; sequence: number; token: number }>((a, b) => a.dueAt - b.dueAt || a.sequence - b.sequence); // active 跟踪整条异步流水线(包含发送前/后的持久化等待),不是仅统计正在进行的 HTTP 请求。 // 句柄/AbortController 属于调度器,任务完成事实仍必须回到 ledger。 const tokens = new Map(), active = new Map; kind: FrozenRequest['kind'] }>(); // 多个恢复/关闭流程可独立 hold;只有最后一个 token 释放才继续准入,已启动工作仍正常收尾。 const holds = new Set(); const ordered: { id: Id; sequence: number }[] = []; let scheduledPump: Promise | undefined; // 上传 lane 单并发等容量限制不能让到期请求反复立即入堆,先 deferred,active 完成再唤醒。 const laneWaiters = new Map, Set>(), deferredRetries = new Map(); let timer: unknown, pumpTimer: unknown, enqueueSequence = 0, stopped = true, closed = false, pumping = false; /** 默认指数退避封顶 30s,最少 1ms;端口可提供 retryAfterMs 覆盖此次等待。 */ const delay = (attempt: number) => Math.max(1, options.retryDelay?.(attempt) ?? Math.min(30_000, 500 * 2 ** Math.min(attempt, 6))); /** 隔离错误报告回调,日志/宿主提示失败不能改变已知回执状态。 */ const report = (error: unknown) => { try { options.onError(error); } catch { /* Reporting cannot change a receipt. */ } }; /** 旧冻结协议必须有解释器才重试;缺钩子时仅默认支持协议 1。 */ const supported = (request: FrozenRequest) => options.supportsRequest?.(request) ?? request.protocolVersion === 1; /** 总 active 上限之外,asset.upload 至多占一个 lane;其它类别可用剩余流水线执行位。 */ const canRun = (kind: FrozenRequest['kind']) => active.size < concurrency && (kind !== 'asset.upload' || ![...active.values()].some(task => task.kind === 'asset.upload')); /** 先读 ledger 检查 pending/依赖/门禁,再用 queued 去重入 FIFO;不是通知一次就盲发一次。 */ function enqueue(id: Id) { if (queued.has(id)) return; const record = options.getLedger().operations.get(id); if (!record || record.phase !== 'pending' || record.blockers.length || record.intent.dependencies.some(dependency => options.getLedger().operations.get(dependency)?.phase !== 'resolved')) {admissionWaiters.delete(id);return;} if(options.admit&&!options.admit(record.intent)){admissionWaiters.add(id);return;} admissionWaiters.delete(id);queued.add(id); ready.push(id); } /** 新逻辑 ID 仅索引一次:二分插入 sequence 表、登记反向依赖,再尝试入队。 */ function index(id: Id) { const record = options.getLedger().operations.get(id); if (!record) return; if (!indexed.has(id)) { indexed.add(id); let lo = 0, hi = ordered.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (ordered[mid].sequence < record.intent.sequence) lo = mid + 1; else hi = mid; } ordered.splice(lo, 0, { id, sequence: record.intent.sequence }); for (const dependency of record.intent.dependencies) { const set = dependents.get(dependency) ?? new Set(); set.add(id); dependents.set(dependency, set); } } enqueue(id); } /** Only a contiguous mutable document prefix may share a request. Task barriers stay visible. */ /** 只选连续未冻结 document.change 前缀,最多 64 意图;追加候选时不让累计 changes 超过 10000。 * 首意图本身超过 10000 时仍可单独返回,不把它拆成字段请求;遇任务屏障/占用即停。 * 仅合并有相交参与者的 lane;独立 canvas 保留各自失败/重试边界。 */ function batch(first: OperationIntent, canvases: Set): readonly OperationIntent[] { const selected = [first], ids = new Set([first.id]); if ((!options.compileBatch && !options.elide) || first.operationType !== 'document.change' || first.operationSchemaVersion !== 1) return selected; let lo = 0, hi = ordered.length, changeCount = first.changes.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (ordered[mid].sequence <= first.sequence) lo = mid + 1; else hi = mid; } const ledger = options.getLedger(); for (let i = lo; i < ordered.length && selected.length < 64; i++) { const record = ledger.operations.get(ordered[i].id); if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || record.intent.operationType !== 'document.change' || record.intent.operationSchemaVersion !== 1) break; if (record.intent.dependencies.some(id => !ids.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break; if(options.admit&&!options.admit(record.intent))break; const scope = options.participants(record.intent); // Independent canvas lanes keep independent failure and retry boundaries. if (!scope.length || !scope.some(id => canvases.has(id)) || scope.some(id => claims.has(id)) || changeCount + record.intent.changes.length > 10_000) break; selected.push(record.intent); ids.add(record.intent.id); changeCount += record.intent.changes.length; for (const id of scope) canvases.add(id); } return selected; } /** 支持合批时延到微任务,让同轮连续编辑有机会聚合;其它情况直接 pump。 */ function requestPump() { if (!options.compileBatch && !options.elide) { pump(); return; } if (!scheduledPump) scheduledPump = Promise.resolve().then(() => { scheduledPump = undefined; pump(); }); } /** 先检查全部 canvas 无冲突,再统一设置 claims,避免持有一部分后等待另一部分。 */ function own(request: FrozenRequest) { for (const canvas of request.canvasIds) { const prior = claims.get(canvas); if (prior && prior !== request.id) throw fault('recovery_claim_conflict', [canvas]); } for (const canvas of request.canvasIds) claims.set(canvas, request.id); } /** 释放仍属于原 request 的 claims,并唤醒对应 canvas 等待者及逻辑依赖者。 */ function release(request: FrozenRequest) { for (const canvas of request.canvasIds) if (claims.get(canvas) === request.id) { claims.delete(canvas); const waiting = waiters.get(canvas); waiters.delete(canvas); for (const id of waiting ?? []) enqueue(id); } for (const id of request.operationIds) for (const dependent of dependents.get(id) ?? []) enqueue(dependent); } /** 可选 mutate 写回 retry 元数据,再用新 token 入 heap;persist=false 仅表示不重复写账本。 * 此处没有 await 存储屏障,真正可靠落盘仍由 launch/resume 的 options.persist 完成。 */ function schedule(id: Id, dueAt: number, action: 'submit' | 'query', persist = true) { if (persist) options.mutate(ledger => ledger.scheduleRetry(id, dueAt, action)); const token = (tokens.get(id) ?? 0) + 1; tokens.set(id, token); retry.push({ id, dueAt, sequence: ++enqueueSequence, token }); armTimer(); } /** 忽略已被新计划替代或已解决请求的旧 heap 条目。 */ function validRetry(item: { id: Id; token: number }) { return tokens.get(item.id) === item.token && options.getLedger().requests.get(item.id)?.phase !== 'resolved'; } /** 仅为最早有效重试设一个 timer;暂停、hold 或容量满时不安排无效唤醒。 */ function armTimer() { if (timer !== undefined) { clock.clearTimer(timer); timer = undefined; } while (retry.peek() && !validRetry(retry.peek()!)) retry.pop(); if (stopped || holds.size || active.size >= concurrency || !retry.peek()) return; timer = clock.setTimer(() => { timer = undefined; pump(); }, Math.max(0, retry.peek()!.dueAt - clock.now())); } /** 安装 active 句柄后在微任务中执行:submit 先标 dispatch;query 保留现有发送风险,再共同等待持久化。 * 调端口→同步 complete→再次持久化;hold 不撤回已启动流水线,stop 则让晚到结果不再采纳。 */ function launch(id: Id, action: 'submit' | 'query') { if (active.has(id) || stopped || holds.size) return; const controller = new AbortController(); // Start in a microtask so the handle exists before any synchronous adapter error. const promise = Promise.resolve().then(async () => { try { if (stopped) return; if (action === 'submit') options.mutate(ledger => ledger.markDispatch(id)); // This barrier includes the exact body/key and the conservative dispatch marker. // 请求身份与保守发送标记在这里可靠存储;失败就不接触外部端口,便于重开查询原请求。 await options.persist(); if (stopped) return; const request = options.getLedger().requests.get(id)!; let status: OperationStatus; try { status = await (action === 'submit' ? options.submit(request, controller.signal) : options.query(request, controller.signal)); } catch { status = { outcome: 'unresolved', error: 'transport_unknown' }; } if (stopped) return; // A stale identity/runtime cannot accept a late callback. // complete 可接受执行证据但阻塞后续协调;不能把网络 2xx 本身等同于本地基线已完成。 const completion = options.complete(request, status); const current = options.getLedger().requests.get(id)!; if (current.phase === 'resolved') { tokens.delete(id); await options.persist(); if (!stopped) release(current); } else if (completion?.blocked || status.blocked) { await options.persist(); } else { // A missing receipt is unknown, so the only write retry reuses this frozen request. // 首次 submit 未知先 query;query 仍未知才原样重试 submit;accepted 一直查,不换请求身份。 // accepted 的查询/submit 都继续 query;其它 submit 结果先 query,非 accepted 的 query 结果才允许同身份 submit。 const nextAction = status.outcome === 'accepted' || action === 'submit' ? 'query' : 'submit'; schedule(id, clock.now() + Math.max(1, status.retryAfterMs ?? delay(current.attempt)), nextAction); await options.persist(); } } catch (error) { report(error); } // Storage/reconciliation failure keeps its claims held. }).finally(() => { // 真实句柄结束后归还容量,重入 deferred retries/可用 lane,再 pump 独立工作。 active.delete(id); if (!stopped) { for (const item of deferredRetries.values()) retry.push(item); deferredRetries.clear(); for (const [kind, waiting] of laneWaiters) if (canRun(kind)) { for (const operationId of waiting) enqueue(operationId); laneWaiters.delete(kind); } pump(); } }); active.set(id, { controller, promise, kind: options.getLedger().requests.get(id)?.kind }); } /** 防同步重入地处理到期请求,再从本轮 ready 前缀最多取 64 项;retry 循环没有同样的 64 项上限。 * 等待者移到索引,ready 尚有余量且有容量时安排下一轮 timer,不代表总队列大小有上限。 */ function pump() { if (stopped || holds.size || pumping) return; pumping = true; try { // 先处理到期恢复请求;上传 lane 被占时暂存,避免它阻塞独立文档请求。 while (active.size < concurrency && retry.peek() && retry.peek()!.dueAt <= clock.now()) { const item = retry.pop()!; if (!validRetry(item)) continue; const request = options.getLedger().requests.get(item.id); if (request && !active.has(item.id)) { if (!canRun(request.kind)) { deferredRetries.set(item.id, item); continue; } launch(item.id, request.retryAction ?? 'query'); } } let budget = Math.min(ready.size, 64); while (active.size < concurrency && budget-- > 0) { const id = ready.pop(); if (!id) break; queued.delete(id); const record = options.getLedger().operations.get(id); if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || record.intent.dependencies.some(dependency => options.getLedger().operations.get(dependency)?.phase !== 'resolved')) continue; try { if(options.admit&&!options.admit(record.intent)){admissionWaiters.add(id);continue;} if (!canRun(record.intent.operationType)) { const set = laneWaiters.get(record.intent.operationType) ?? new Set(); set.add(id); laneWaiters.set(record.intent.operationType, set); continue; } const canvases = [...new Set(options.participants(record.intent))].sort(); if (!canvases.length && record.intent.operationType !== 'asset.upload' && record.intent.operationType !== 'run.cancel') throw fault('missing_participants'); // 同时获取全部参与者,无“先锁 A 再等 B”;占用冲突时只登记等待,不持有局部 claim。 const occupied = canvases.filter(canvas => claims.has(canvas)); if (occupied.length) { for (const canvas of occupied) { const set = waiters.get(canvas) ?? new Set(); set.add(id); waiters.set(canvas, set); } continue; } const scope = new Set(canvases), selected = batch(record.intent, scope); const limit = options.batchLimit?.(selected) ?? selected.length; if (!Number.isSafeInteger(limit) || limit < 1 || limit > selected.length) throw fault('invalid_batch_limit'); const candidates = selected.slice(0, limit); // 可证明无外部效果的闭合前缀可本地抵消;必须核对实际 ledger 已标 elided,不能只信返回 ID。 const elided = options.elide?.(candidates) ?? []; if (elided.length) { if (elided.some((id, index) => id !== candidates[index]?.id || options.getLedger().operations.get(id)?.resolution !== 'elided')) throw fault('invalid_elision'); for (const id of elided) for (const dependent of dependents.get(id) ?? []) enqueue(dependent); continue; } const intents = options.compileBatch ? candidates : [record.intent]; const participants = [...new Set(intents.flatMap(intent => options.participants(intent)))].sort(); // 编译参与者必须与实际 intents 推导的集合一致,随后 freeze/own/launch;没有 compileBatch 时只编译首意图。 // 异常走原逻辑 ID 的 block;若已 frozen,ledger.block 不会把它退回 pending,冻结身份仍保留。 const requestId = options.createId(), compiled = intents.length > 1 ? options.compileBatch!(intents, requestId) : options.compile(record.intent, requestId); if (participants.length !== compiled.canvasIds.length || participants.some(canvas => !compiled.canvasIds.includes(canvas))) throw fault('participant_mismatch'); options.mutate(ledger => ledger.freeze({ ...compiled, id: requestId, operationIds: intents.map(intent => intent.id) })); own(options.getLedger().requests.get(requestId)!); launch(requestId, 'submit'); } catch (error) { options.mutate(ledger => ledger.block(id, [(error as Error).message || 'compile_failed'])); report(error); } } } catch(error) { // A retired identity can reject both the attempt and its blocked-state write. // Report the failure without rejecting an unobserved scheduled pump promise. report(error); } finally { pumping = false; armTimer(); if (!stopped && !holds.size && ready.size && active.size < concurrency && pumpTimer === undefined) pumpTimer = clock.setTimer(() => { pumpTimer = undefined; pump(); }, 0); } } return Object.freeze({ /** 首次启动(已运行时幂等):先恢复全部未解决 claims,之后才开始端口工作;claim 冲突会在仍 stopped 时抛错。 * 解释器缺失保留占用并写 blocker;此步骤不做完整恢复 DTO 校验,也不从单个错误推断请求未执行。 */ start() { if (closed) throw fault('scheduler_closed'); if (!stopped) return; // Validate the entire recovered claim set before sending anything. claims.clear(); for (const request of options.getLedger().requests.values()) if (request.phase !== 'resolved') own(request); stopped = false; for (const operation of [...options.getLedger().operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) index(operation.intent.id); for (const request of options.getLedger().requests.values()) if (request.phase !== 'resolved') { if (!supported(request)) { options.mutate(ledger => ledger.blockRequest(request.id, 'request_interpreter_unavailable')); continue; } schedule(request.id, request.retryAt ?? clock.now(), request.retryAction ?? (request.phase === 'frozen' ? 'submit' : 'query'), false); } pump(); }, /** 门禁事实变化后显式重试准入,未满足者继续等待,不制造定时器忙轮询。 */ refreshAdmission(){for(const id of [...admissionWaiters])enqueue(id);if(ready.size)requestPump();}, /** 新逻辑操作通知推进派生索引;正在 queued 时不重复入队,indexed 让依赖只注册一次。 * 已出队的 pending 操作后续 notify 可再次尝试准入;通知本身不新增 ledger 意图。 */ notify(operationIds: readonly Id[]) { for (const id of operationIds) index(id); requestPump(); }, /** Acquire synchronously, then settle(): already admitted work completes with its original identity. */ /** 同步阻止新准入并清唤醒 timer;返回幂等释放函数,已准入异步工作不被撤销或换身份。 */ hold() { if (closed) throw fault('scheduler_closed'); const token = Symbol(); holds.add(token); if (timer !== undefined) clock.clearTimer(timer); if (pumpTimer !== undefined) clock.clearTimer(pumpTimer); timer = pumpTimer = undefined; return () => { if (!holds.delete(token) || holds.size || stopped) return; pump(); }; }, /** Explicit retry after storage/auth/permission recovery; preserves frozen bodies. */ /** 存储/授权恢复后的显式重试:先补持久化,再释放已解决 claims 或继续查原请求。 */ async resume() { if (stopped) return; // A receipt may be in memory after its durable write failed. Keep claims until retry succeeds. await options.persist(); if (stopped) return; for (const request of options.getLedger().requests.values()) { if (active.has(request.id)) continue; if (request.phase === 'resolved') release(request); else if (supported(request)) schedule(request.id, request.retryAt ?? clock.now(), request.retryAction ?? 'query', false); } for (const id of indexed) enqueue(id); pump(); }, /** 返回当前占用表的只读副本,调用者不能手改 claim 绕过未知请求。 */ getClaims: () => readMap(claims), /** 等待当前微任务/pump/active 收尾,不主动等待未来 retry timer,因此不是“全部已同步”保证。 * 端口若长期不完成(包括忽略 abort),active Promise 仍可让 settle 一直等待。 */ async settle() { do { if (scheduledPump) await scheduledPump; if (active.size) await Promise.all([...active.values()].map(item => item.promise)); if (!stopped && !holds.size && pumpTimer !== undefined) { clock.clearTimer(pumpTimer); pumpTimer = undefined; pump(); } } while (scheduledPump || active.size || (!stopped && !holds.size && pumpTimer !== undefined)); }, /** 永久关闭本 scheduler、取消 timer 并请求 abort active;晚到响应因 stopped 不再采纳,不能再 start。 * abort 不证明外部未执行,也不清 ledger/claims/派生容器或替 engine 关闭存储;活动句柄在 finally 收尾。 */ stop() { closed = true; stopped = true; if (timer !== undefined) clock.clearTimer(timer); if (pumpTimer !== undefined) clock.clearTimer(pumpTimer); timer = pumpTimer = undefined; for (const item of active.values()) item.controller.abort(); }, }); }