docs(runtime): clarify planning checkpoints and scope evidence

This commit is contained in:
2026-11-12 13:23:33 +08:00
parent 17c14c85e8
commit 896e6a0656
2 changed files with 27 additions and 8 deletions
+17 -4
View File
@@ -23,7 +23,10 @@ export type DuplicateScheduling = {
* No document publication or network effects. The caller must persist source/mapping and
* revalidate external identities before applying a plan; this is not the import executor.
*/
/** 分片消费完整plan迭代器,每片默认6ms/512步;返回前全部记录已生成并校验,但不执行导入。 */
/** 分片消费完整 plan 生成器,默认每片 6ms/512 个语义步,完成后返回全量计划而非逐条发布。
* checkpoint 仅接收累计步数,不含映射/生成器状态,不能用该数字恢复规划;最终返回前不保证再调用一次。
* 每次 next 本身可包含同步固定成本,软预算不能抢占它;now/yieldTask 决定实际调度,默认 timer 让出。
* 异常/取消关闭生成器,不回滚已调用 createId 或 checkpoint 的外部副作用;调用方负责可靠源/映射保存。 */
export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise<DuplicatePlan> {
const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512;
if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget');
@@ -58,7 +61,11 @@ export async function planDuplicateAsync(document: Document, registry: Registry,
}
/** Resume semantic planning cursors. Source/output validation and derived output caches are rebuilt. */
/** 以完整checkpoint恢复语义游标,仍重做源验证/派生输出缓存,不声称从任意字节零重算。 */
/** 从完整 DuplicateCheckpoint 恢复语义游标/已分配 ID,源验证和派生输出缓存仍由 planner 重建。
* 每个未完成时间片可将不可变 checkpoint 交宿主 await;回调成功只代表宿主 Promise 成功,不独自证明落盘。
* planner.step 完成后直接 result,不额外回调最终状态;需要最终可靠切点时由调用方保存返回计划/状态。
* selection/偏移/映射在入口捕获,document/registry 沿不可变快照合同使用;调度选项应保持稳定。
* 与完整生成器入口一样,不发布业务文档、不补偿已分配 ID 或宿主 checkpoint 副作用。 */
export async function planDuplicateResumable(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>}={}):Promise<DuplicatePlan>{
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
@@ -73,7 +80,12 @@ export async function planDuplicateResumable(document:Document,registry:Registry
/** Stop before record conversion: the stable order/mappings are sufficient for seekable lazy output. */
/** 只推进到emit起点:范围/顺序/全部新ID已固定,但尚未转换全部payload,可交lazy source逐条输出。 */
/** 仅推进至 ready/emit 起点,返回顺序/映射已固定的 checkpoint;尚未完成所有 payload 转换与输出校验。
* planner 捕获小输入与私有语义 state;此层只调度 step/checkpoint,不用另一个 live 文档替换源。
* checkpointIntervalMs=0 默认每个未完成切片保存;>0 时按阶段/首次分配/时间/步数门槛节奏化保存。
* 阈值只在切片末检查,maxStepsPerCheckpoint 可能多跨一片;不是逐语义步的硬持久间隔。
* ready 时只有 interval>0 且有回调才补一次保存;默认路径可直接返回,宿主仍须持久化返回状态。
* topologyOnly 仅选择源拓扑检查,不证明全部组件 payload 可复制;懒源逐条转换时仍可报记录失败。 */
export async function prepareDuplicateStream(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>;checkpointIntervalMs?:number;maxStepsPerCheckpoint?:number}={}):Promise<DuplicateCheckpoint>{
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
@@ -82,7 +94,8 @@ export async function prepareDuplicateStream(document:Document,registry:Registry
const interval=scheduling.checkpointIntervalMs??0,maxCheckpointSteps=scheduling.maxStepsPerCheckpoint??8192;
if(!Number.isFinite(interval)||interval<0||!Number.isSafeInteger(maxCheckpointSteps)||maxCheckpointSteps<1)throw fault('invalid_copy_budget');
const planner=createDuplicatePlanner(document,registry,selection,options,binding);
// savedSteps等只记录上次已等待checkpoint回调成功的位置,保存耗时不冒充新增规划工作。
// saved* 是调度内存,不是另一份持久 checkpoint;首次 savedPhase 未知会在首片触发回调。
// savedAt 只在回调成功后重取,保存耗时不算新增规划工作,也不会单因慢存储强迫下一片立即再存。
let savedPhase:DuplicateCheckpoint['phase']|undefined,savedSteps=binding.checkpoint?.steps??0,savedAllocated=(binding.checkpoint?.elementIds.length??0)+(binding.checkpoint?.canvasIds.length??0),savedAt=now();
while(true){
const start=now();let count=0;
+10 -4
View File
@@ -8,7 +8,9 @@ import {fault} from '../canvas-core/values.ts';
/** Snapshot-bound direct membership counts, including nodes and connections. */
/** 当前捕获快照的直属数量,包含节点和边;不是递归子树大小或 DOM 已挂载数量。 */
export type CanvasElementCounts=Readonly<Record<Id,number>>;
/** 要求 counts 精确覆盖 canvasIds、每项非负安全整数,且总和等于声明 total。 */
/** 检查自有可枚举键数量及 canvasIds 的每个键存在,计数为非负安全整数,总和安全且等于 total。
* canvasIds 须由调用方保证唯一;此函数不单独去重,重复声明会破坏“键数量相同即覆盖相同”的前提。
* 只验证声明内部一致,不读取真实元素、认证服务或证明后续 payload 已齐;输入也不在此捕获/冻结。 */
export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[],total:number):asserts value is CanvasElementCounts {
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_model_coverage');
const counts=value as Record<Id,unknown>,keys=Object.keys(counts);let sum=0;
@@ -20,7 +22,9 @@ export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[
/** All active IDs in the capture, including records not yet received. */
/** 完整活 ID 命名空间,即使部分记录尚未传到客户端,也预先知道其直接 owner。 */
export type ElementOwners=Readonly<Record<Id,Id>>;
/** 核验每个活 ID 的 owner 合法,并按 owner 反算数量与 counts 一致,不靠已读前缀猜完整性。 */
/** 要求声明恰有 total 个自有可枚举活 ID,每个非空 ID 的 owner 是 counts 中已有键,再反算数量。
* counts 应先经计数校验;此处不独立验证其值域,也不与已读 document 比较 ID/owner 是否对应。
* 只做同步声明一致性检查,不捕获/冻结输入;实际 payload 到达后还需匹配这份源快照身份表。 */
export function validateElementOwners(value:unknown,counts:CanvasElementCounts,total:number):asserts value is ElementOwners {
if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==total)throw fault('invalid_model_identity');
// 无原型计数表避免特殊 owner 键污染计数;空 canvas 由缺省 0 与 counts 比较。
@@ -30,8 +34,10 @@ export function validateElementOwners(value:unknown,counts:CanvasElementCounts,t
}
/** Full captured topology, independent of which entry payloads have arrived. */
/** 用完整 topology+owner 证明验证单根、父存在、入口唯一/归属正确和无环。
* 此时入口记录可能尚未收到,入口是否确为 node 仍需 payload 到达后的文档校验。 */
/** 用源 canvas 表和已验证 owners 声明检查唯一根=projectId、父存在、入口唯一/归属与无环。
* 不是完整 CanvasRecord/payload schema 校验;未收到入口时不能证明其为 node、组件可用或实际拥有关系一致。
* 每个子 canvas 重新走父链,深树会反复访问共享祖先;同步检查无预算/取消,应由捕获规模约束成本。
* 不捕获输入或标记文档已完整;加载器另需核对已收到树/元素与此声明,再建立 owner 的覆盖状态。 */
export function validateCanvasTopology(canvases:readonly import('../canvas-core/types.ts').CanvasRecord[],owners:ElementOwners,projectId:Id):void {
if(!Array.isArray(canvases)||!canvases.length)throw fault('invalid_model_topology');
const table=new Map(canvases.map(canvas=>[canvas?.id,canvas]));if(table.size!==canvases.length||!table.has(projectId))throw fault('invalid_model_topology');