DAG: execute recompute levels concurrently
This commit is contained in:
@@ -324,7 +324,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
|
|||||||
|
|
||||||
| 任务 | 实施措施 | 验证结果 | 剩余边界 |
|
| 任务 | 实施措施 | 验证结果 | 剩余边界 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation;后发运行取消前发;节点完成后校验 AbortSignal、文档 ID/版本和当前 generation;结果状态为 `completed/failed/cancelled/stale` | 取消、过期版本、依赖失败传播和 Facade 提交测试通过 | 每个 PartDesign 特征尚未绑定真实 OCCT 输入/输出 Shape;尚未实现 Worker 并行层 |
|
| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation;后发运行取消前发;节点完成后校验 AbortSignal、文档 ID/版本和当前 generation;结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度 | 取消、过期版本、依赖失败传播、独立节点并行和 Facade 提交测试通过 | 每个 PartDesign 特征尚未绑定真实 OCCT 输入/输出 Shape;同层并行目前是调度层并发,不等同于每节点独立 Worker;尚未实现最近有效 Shape 保留 |
|
||||||
| DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 FreeCAD 状态机和 Report 树仍待实现 |
|
| DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 FreeCAD 状态机和 Report 树仍待实现 |
|
||||||
| SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver,不是 `planegcs` WASM;高级约束、拖拽、外部几何和 B-spline 未完成 |
|
| SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver,不是 `planegcs` WASM;高级约束、拖拽、外部几何和 B-spline 未完成 |
|
||||||
| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
|
| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
|
||||||
|
|||||||
@@ -1476,7 +1476,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| P4-01 | 实现 Document/Object/Container/Feature/Shape | 领域包和序列化投影 | G1 | UUID、类型、标签和生命周期稳定 |
|
| P4-01 | 实现 Document/Object/Container/Feature/Shape | 领域包和序列化投影 | G1 | UUID、类型、标签和生命周期稳定 |
|
||||||
| P4-02 | 实现 Property/Link/Expression/Unit | 元数据、校验和单位换算 | P4-01 | Data/View 编辑器由 metadata 驱动 |
|
| P4-02 | 实现 Property/Link/Expression/Unit | 元数据、校验和单位换算 | P4-01 | Data/View 编辑器由 metadata 驱动 |
|
||||||
| P4-03 | 实现依赖 DAG、脏标记和拓扑调度 | recompute scheduler、稳定 `levels` | P4-01 | 只重算受影响闭包;同层对象无依赖,可安全交给并行 Worker |
|
| P4-03 | 实现依赖 DAG、脏标记和拓扑调度 | recompute scheduler、稳定 `levels`、同层并发执行 | P4-01 | 只重算受影响闭包;同层对象无依赖;调度器以 `Promise.all` 并发执行并按计划顺序提交结果,未来再下沉到并行 Worker |
|
||||||
| P4-04 | 实现事务、撤销/重做和保存点 | Command Bus、undo journal | P4-03 | 100 次撤销/重做可回到原 hash |
|
| P4-04 | 实现事务、撤销/重做和保存点 | Command Bus、undo journal | P4-03 | 100 次撤销/重做可回到原 hash |
|
||||||
| P4-05 | 实现 Body/Tip/特征顺序/可见性 | PartDesign 领域规则 | P4-01/P3-04 | 非法多实体和 Tip 操作被拒绝 |
|
| P4-05 | 实现 Body/Tip/特征顺序/可见性 | PartDesign 领域规则 | P4-01/P3-04 | 非法多实体和 Tip 操作被拒绝 |
|
||||||
| P4-06 | 实现诊断树和失败恢复 | Diagnostic model、repair actions | P4-03 | 失败节点可定位、回退或修复 |
|
| P4-06 | 实现诊断树和失败恢复 | Diagnostic model、repair actions | P4-03 | 失败节点可定位、回退或修复 |
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
|
|||||||
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
|
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
|
||||||
const systemCommands = new Set(['new-document', 'save', 'select-object'])
|
const systemCommands = new Set(['new-document', 'save', 'select-object'])
|
||||||
const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'fillet', 'chamfer', 'solve-sketch'])
|
const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'fillet', 'chamfer', 'solve-sketch'])
|
||||||
|
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'fillet', 'chamfer'])
|
||||||
const featureCommands: Record<string, { label: string; detail: string }> = {
|
const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||||
'create-body': { label: 'Body', detail: 'Part Design body' },
|
'create-body': { label: 'Body', detail: 'Part Design body' },
|
||||||
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
|
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
|
||||||
@@ -146,7 +147,8 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
|
|||||||
const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
|
const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
|
||||||
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
|
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
|
||||||
if (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' }
|
if (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' }
|
||||||
if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' }
|
if (partDesignCommands.has(commandId) && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: `Switch to Part Design to use ${commandId}.` }
|
||||||
|
if ((commandId === 'new-sketch' || commandId === 'solve-sketch') && activeWorkbench !== 'Sketcher') return { id: commandId, status: 'disabled', reason: `Switch to Sketcher to use ${commandId}.` }
|
||||||
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
|
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
|
||||||
return { id: commandId, status: 'enabled' }
|
return { id: commandId, status: 'enabled' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,26 +128,19 @@ export class RecomputeCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const objectId of plan.order) {
|
let processed = 0
|
||||||
|
for (const level of plan.levels) {
|
||||||
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
|
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
|
||||||
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
|
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
|
||||||
|
|
||||||
|
// Nodes in a level have no dependencies on each other. Execute them together,
|
||||||
|
// then merge outcomes in plan order so persistence and UI events stay deterministic.
|
||||||
|
const outcomes = await Promise.all(level.map(async (objectId) => {
|
||||||
const failedDependency = graph.dependenciesOf(objectId).find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')
|
const failedDependency = graph.dependenciesOf(objectId).find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')
|
||||||
if (failedDependency) {
|
if (failedDependency) return { objectId, state: 'upstream-failed' as const, error: { objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` } }
|
||||||
objectStates[objectId] = 'upstream-failed'
|
|
||||||
skipped.push(objectId)
|
|
||||||
errors.push({ objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` })
|
|
||||||
options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: 'upstream-failed' })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const object = objectById.get(objectId)
|
const object = objectById.get(objectId)
|
||||||
if (!object) {
|
if (!object) return { objectId, state: 'error' as const, error: { objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` } }
|
||||||
objectStates[objectId] = 'error'
|
|
||||||
failed.push(objectId)
|
|
||||||
errors.push({ objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.executeNode(object, document, {
|
const result = await this.executeNode(object, document, {
|
||||||
@@ -156,24 +149,40 @@ export class RecomputeCoordinator {
|
|||||||
generation,
|
generation,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
return {
|
||||||
|
objectId,
|
||||||
|
state: 'error' as const,
|
||||||
|
errors: result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { objectId, state: 'up-to-date' as const, updatedObject: result.updatedObject }
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted || isAbortError(error)) return { objectId, state: 'cancelled' as const }
|
||||||
|
return { objectId, state: 'error' as const, error: { objectId, code: 'RECOMPUTE_EXCEPTION', message: error instanceof Error ? error.message : String(error) } }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
|
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
|
||||||
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
|
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
|
||||||
if (result.status === 'failed') {
|
|
||||||
objectStates[objectId] = 'error'
|
for (const outcome of outcomes) {
|
||||||
failed.push(objectId)
|
if (outcome.state === 'cancelled') return terminalResult('cancelled')
|
||||||
errors.push(...(result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }]))
|
objectStates[outcome.objectId] = outcome.state
|
||||||
|
processed += 1
|
||||||
|
if (outcome.state === 'up-to-date') {
|
||||||
|
completed.push(outcome.objectId)
|
||||||
|
if (outcome.updatedObject) objectUpdates.push(outcome.updatedObject)
|
||||||
|
} else if (outcome.state === 'upstream-failed') {
|
||||||
|
skipped.push(outcome.objectId)
|
||||||
|
if (outcome.error) errors.push(outcome.error)
|
||||||
} else {
|
} else {
|
||||||
objectStates[objectId] = 'up-to-date'
|
failed.push(outcome.objectId)
|
||||||
completed.push(objectId)
|
if ('errors' in outcome && outcome.errors) errors.push(...outcome.errors)
|
||||||
if (result.updatedObject) objectUpdates.push(result.updatedObject)
|
else if (outcome.error) errors.push(outcome.error)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
options.onProgress?.({ generation, documentVersion: document.version, objectId: outcome.objectId, completed: processed, total: plan.order.length, state: outcome.state })
|
||||||
if (controller.signal.aborted || isAbortError(error)) return terminalResult('cancelled')
|
|
||||||
objectStates[objectId] = 'error'
|
|
||||||
failed.push(objectId)
|
|
||||||
errors.push({ objectId, code: 'RECOMPUTE_EXCEPTION', message: error instanceof Error ? error.message : String(error) })
|
|
||||||
}
|
}
|
||||||
options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: objectStates[objectId] })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.active?.generation === generation) this.active = null
|
if (this.active?.generation === generation) this.active = null
|
||||||
|
|||||||
@@ -277,6 +277,50 @@ test('generation-aware recompute cancels an older run before accepting its resul
|
|||||||
version = 2
|
version = 2
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('recompute executes independent dependency levels concurrently and merges in stable order', async () => {
|
||||||
|
const document: DocumentSnapshot = {
|
||||||
|
...recomputeDocumentFixture(),
|
||||||
|
tree: [
|
||||||
|
{ id: 'root', label: 'Root', type: 'feature', state: 'dirty' },
|
||||||
|
{ id: 'left', label: 'Left', type: 'feature', state: 'dirty' },
|
||||||
|
{ id: 'right', label: 'Right', type: 'feature', state: 'dirty' },
|
||||||
|
],
|
||||||
|
objects: [
|
||||||
|
{ id: 'root', typeId: 'Part::Feature', properties: [] },
|
||||||
|
{ id: 'left', typeId: 'Part::Feature', properties: [] },
|
||||||
|
{ id: 'right', typeId: 'Part::Feature', properties: [] },
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
{ sourceId: 'left', targetId: 'root', relation: 'link' },
|
||||||
|
{ sourceId: 'right', targetId: 'root', relation: 'link' },
|
||||||
|
],
|
||||||
|
recompute: {
|
||||||
|
generation: 0,
|
||||||
|
status: 'idle',
|
||||||
|
objectStates: { root: 'touched', left: 'touched', right: 'touched' },
|
||||||
|
dirtyObjects: ['root'],
|
||||||
|
order: [],
|
||||||
|
errors: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let active = 0
|
||||||
|
let maximumActive = 0
|
||||||
|
const coordinator = new RecomputeCoordinator(async (object) => {
|
||||||
|
active += 1
|
||||||
|
maximumActive = Math.max(maximumActive, active)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||||
|
active -= 1
|
||||||
|
return { status: 'success', updatedObject: object }
|
||||||
|
}, () => 1)
|
||||||
|
|
||||||
|
const result = await coordinator.run(document)
|
||||||
|
assert.equal(result.status, 'completed')
|
||||||
|
assert.equal(maximumActive, 2)
|
||||||
|
assert.deepEqual(result.order, ['root', 'left', 'right'])
|
||||||
|
assert.deepEqual(result.completed, ['root', 'left', 'right'])
|
||||||
|
assert.deepEqual(result.levels, [['root'], ['left', 'right']])
|
||||||
|
})
|
||||||
|
|
||||||
test('recompute rejects results from an obsolete document version', async () => {
|
test('recompute rejects results from an obsolete document version', async () => {
|
||||||
let version = 1
|
let version = 1
|
||||||
const coordinator = new RecomputeCoordinator(async () => {
|
const coordinator = new RecomputeCoordinator(async () => {
|
||||||
@@ -395,6 +439,17 @@ test('manifest commands without a Bitbybit executor are explicitly unsupported',
|
|||||||
assert.equal(facade.getState().document.version, before)
|
assert.equal(facade.getState().document.version, before)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('implemented commands enforce their FreeCAD workbench preconditions', () => {
|
||||||
|
const facade = createMockFacade()
|
||||||
|
facade.gui.workbench.setActive('Sketcher')
|
||||||
|
assert.equal(facade.gui.command.getState('create-body').status, 'disabled')
|
||||||
|
assert.match(facade.gui.command.getState('create-body').reason || '', /Part Design/)
|
||||||
|
assert.equal(facade.gui.command.getState('new-sketch').status, 'enabled')
|
||||||
|
facade.gui.workbench.setActive('Part Design')
|
||||||
|
assert.equal(facade.gui.command.getState('new-sketch').status, 'disabled')
|
||||||
|
assert.equal(facade.gui.command.getState('pocket').status, 'enabled')
|
||||||
|
})
|
||||||
|
|
||||||
test('command events carry a document-scoped request context', () => {
|
test('command events carry a document-scoped request context', () => {
|
||||||
const facade = createMockFacade()
|
const facade = createMockFacade()
|
||||||
const events: string[] = []
|
const events: string[] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user