P2-02: serialize persistence writes
This commit is contained in:
@@ -1590,9 +1590,9 @@ P0 基线/治理
|
||||
| P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 |
|
||||
| P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker |
|
||||
| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty 标志和 Undo/Redo 已接通,完整属性、依赖 DAG 和重计算仍待实现 |
|
||||
| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v1;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;迁移回滚、队列压力和恢复演练仍待补齐 |
|
||||
| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v1;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 |
|
||||
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 |
|
||||
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 |
|
||||
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 |
|
||||
|
||||
本迭代验证命令:`npm run check:facade-boundary`、`npm run test:facade`、`npm run build`。构建产物将 Three.js 拆为独立 chunk,避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。
|
||||
|
||||
@@ -1609,4 +1609,4 @@ P0 基线/治理
|
||||
| Three.js WebGL2 | `PASS` | Chrome SwiftShader 工作区检测到 1 个 canvas 且 `getContext('webgl2')` 成功 |
|
||||
| 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter;不会假称 OPFS 已可用 |
|
||||
|
||||
仍未通过 G4/G5:自动保存队列、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。
|
||||
仍未通过 G4/G5:自动保存触发策略、1000 次压力报告、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { createMockFacade } from './mockFacade'
|
||||
export { createSqliteProjectPersistence, SqliteProjectPersistence } from './projectStore'
|
||||
export { createSqliteProjectPersistence, PersistenceWriteQueue, SqliteProjectPersistence } from './projectStore'
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectSaveResult, TaskSnapshot } from './types'
|
||||
|
||||
@@ -15,6 +15,21 @@ export interface ProjectPersistenceClient {
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Serializes persistence mutations while allowing reads to follow the write tail. */
|
||||
export class PersistenceWriteQueue {
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
|
||||
run<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const next = this.tail.then(operation)
|
||||
this.tail = next.then(() => undefined, () => undefined)
|
||||
return next
|
||||
}
|
||||
|
||||
drain(): Promise<void> {
|
||||
return this.tail
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
private readonly worker: Worker | null
|
||||
private nextRequestId = 0
|
||||
@@ -22,6 +37,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
private initialized: Promise<PersistenceCapabilities> | null = null
|
||||
private readonly fallbackSnapshots = new Map<string, DocumentSnapshot>()
|
||||
private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => void; reject: (error: Error) => void }>()
|
||||
private readonly writeQueue = new PersistenceWriteQueue()
|
||||
|
||||
constructor() {
|
||||
this.worker = typeof Worker === 'undefined' ? null : new Worker(new URL('./persistenceWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-persistence' })
|
||||
@@ -40,15 +56,18 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
return this.initialized
|
||||
}
|
||||
|
||||
async save(document: DocumentSnapshot) {
|
||||
await this.initialize()
|
||||
if (!this.worker) { const snapshot = cloneDocument(document); this.fallbackSnapshots.set(snapshot.id, snapshot); return { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode } }
|
||||
const response = await this.request({ type: 'save-document', document })
|
||||
if (!response.ok || response.type !== 'saved') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
return { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode }
|
||||
save(document: DocumentSnapshot) {
|
||||
return this.writeQueue.run(async () => {
|
||||
await this.initialize()
|
||||
if (!this.worker) { const snapshot = cloneDocument(document); this.fallbackSnapshots.set(snapshot.id, snapshot); return { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode } }
|
||||
const response = await this.request({ type: 'save-document', document })
|
||||
if (!response.ok || response.type !== 'saved') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
return { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode }
|
||||
})
|
||||
}
|
||||
|
||||
async load(documentId: string) {
|
||||
await this.writeQueue.drain()
|
||||
await this.initialize()
|
||||
if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot) : null }
|
||||
const response = await this.request({ type: 'load-document', documentId })
|
||||
@@ -57,6 +76,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.writeQueue.drain()
|
||||
if (!this.worker) return
|
||||
await this.request({ type: 'dispose' })
|
||||
this.worker.terminate()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createMockFacade } from '../src/facade/mockFacade'
|
||||
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema'
|
||||
import { PersistenceWriteQueue } from '../src/facade/projectStore'
|
||||
|
||||
test('facade exposes a stable initial document projection', () => {
|
||||
const facade = createMockFacade()
|
||||
@@ -19,6 +20,28 @@ test('project schema is versioned and covers the FreeCAD document graph', () =>
|
||||
assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/)
|
||||
})
|
||||
|
||||
test('persistence writes are serialized and continue after a failed write', async () => {
|
||||
const queue = new PersistenceWriteQueue()
|
||||
let inFlight = 0
|
||||
let maximumInFlight = 0
|
||||
const order: string[] = []
|
||||
const write = (id: string, shouldFail = false) => queue.run(async () => {
|
||||
inFlight += 1
|
||||
maximumInFlight = Math.max(maximumInFlight, inFlight)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2))
|
||||
order.push(id)
|
||||
inFlight -= 1
|
||||
if (shouldFail) throw new Error('expected write failure')
|
||||
return id
|
||||
})
|
||||
const results = await Promise.allSettled([write('one'), write('two', true), write('three')])
|
||||
assert.equal(maximumInFlight, 1)
|
||||
assert.deepEqual(order, ['one', 'two', 'three'])
|
||||
assert.equal(results[0].status, 'fulfilled')
|
||||
assert.equal(results[1].status, 'rejected')
|
||||
assert.equal(results[2].status, 'fulfilled')
|
||||
})
|
||||
|
||||
test('workbench and selection changes are event driven', () => {
|
||||
const facade = createMockFacade()
|
||||
const events: string[] = []
|
||||
|
||||
Reference in New Issue
Block a user