perf(app): coalesce overlapping autosaves (#531)
- Serialize writable-document autosaves and retain only the newest trailing version - Preserve saves requested while export or persistence is in flight - Cover file, storage, retry, and recovery scheduling invariants
This commit is contained in:
parent
b0ebe91ed6
commit
e7c408a28f
|
|
@ -47,6 +47,7 @@
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
|
- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528)
|
||||||
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500)
|
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500)
|
||||||
- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500)
|
- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500)
|
||||||
- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph.
|
- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ type AutosaveOptions = {
|
||||||
state: AutosaveState
|
state: AutosaveState
|
||||||
getSavedVersion: () => number
|
getSavedVersion: () => number
|
||||||
hasWritableSource: () => boolean
|
hasWritableSource: () => boolean
|
||||||
saveCurrentDocument: () => Promise<void>
|
saveCurrentDocument: (version: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAutosave({
|
export function createAutosave({
|
||||||
|
|
@ -17,20 +17,56 @@ export function createAutosave({
|
||||||
hasWritableSource,
|
hasWritableSource,
|
||||||
saveCurrentDocument
|
saveCurrentDocument
|
||||||
}: AutosaveOptions) {
|
}: AutosaveOptions) {
|
||||||
|
let requestedVersion: number | null = null
|
||||||
|
let saving: Promise<void> | null = null
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
function canSave(version: number) {
|
||||||
|
return version > getSavedVersion() && state.autosaveEnabled && hasWritableSource()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSaves() {
|
||||||
|
while (requestedVersion !== null) {
|
||||||
|
if (disposed) return
|
||||||
|
const version = requestedVersion
|
||||||
|
requestedVersion = null
|
||||||
|
if (!canSave(version)) continue
|
||||||
|
await saveCurrentDocument(version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportFailure(error: unknown) {
|
||||||
|
console.warn('Autosave failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSave(version: number): Promise<void> {
|
||||||
|
if (disposed || !canSave(version)) return Promise.resolve()
|
||||||
|
requestedVersion = Math.max(requestedVersion ?? version, version)
|
||||||
|
if (!saving) {
|
||||||
|
saving = runSaves().finally(() => {
|
||||||
|
saving = null
|
||||||
|
if (!disposed && requestedVersion !== null) {
|
||||||
|
void requestSave(requestedVersion).catch(reportFailure)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return saving
|
||||||
|
}
|
||||||
|
|
||||||
const stop = watchDebounced(
|
const stop = watchDebounced(
|
||||||
() => state.sceneVersion,
|
() => state.sceneVersion,
|
||||||
async (version) => {
|
(version) => {
|
||||||
if (version === getSavedVersion()) return
|
void requestSave(version).catch(reportFailure)
|
||||||
if (!state.autosaveEnabled) return
|
|
||||||
if (!hasWritableSource()) return
|
|
||||||
try {
|
|
||||||
await saveCurrentDocument()
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Autosave failed:', e)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ debounce: 3000 }
|
{ debounce: 3000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
return { disposeAutosave: stop }
|
return {
|
||||||
|
requestSave,
|
||||||
|
disposeAutosave() {
|
||||||
|
disposed = true
|
||||||
|
requestedVersion = null
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,11 @@ export function createDocumentSourceActions({
|
||||||
onDownloadSuccess: (version) => recovery.markProtectedVersion(version)
|
onDownloadSuccess: (version) => recovery.markProtectedVersion(version)
|
||||||
})
|
})
|
||||||
|
|
||||||
const { disposeAutosave } = createAutosave({
|
const autosave = createAutosave({
|
||||||
state,
|
state,
|
||||||
getSavedVersion,
|
getSavedVersion,
|
||||||
hasWritableSource: () => !!getFileHandle() || !!getFilePath() || !!getStorageBinding(),
|
hasWritableSource: () => !!getFileHandle() || !!getFilePath() || !!getStorageBinding(),
|
||||||
saveCurrentDocument: async () => {
|
saveCurrentDocument: async (version) => {
|
||||||
const version = state.sceneVersion
|
|
||||||
const data = await buildFigFile()
|
const data = await buildFigFile()
|
||||||
await writeFile(data, version)
|
await writeFile(data, version)
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +141,7 @@ export function createDocumentSourceActions({
|
||||||
|
|
||||||
function disposeDocumentIO() {
|
function disposeDocumentIO() {
|
||||||
stopWatchingFile()
|
stopWatchingFile()
|
||||||
disposeAutosave()
|
autosave.disposeAutosave()
|
||||||
recovery.disposeRecovery()
|
recovery.disposeRecovery()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
124
tests/engine/app/document/autosave/create.test.ts
Normal file
124
tests/engine/app/document/autosave/create.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
import { createDefaultEditorState } from '@open-pencil/core/editor'
|
||||||
|
|
||||||
|
import { createAutosave } from '@/app/document/autosave/create'
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve: (() => void) | null = null
|
||||||
|
const promise = new Promise<void>((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve: () => resolve?.() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(saveCurrentDocument: (version: number) => Promise<void>) {
|
||||||
|
const state = reactive({
|
||||||
|
...createDefaultEditorState('page-1'),
|
||||||
|
autosaveEnabled: true
|
||||||
|
})
|
||||||
|
let savedVersion = 0
|
||||||
|
let writable = true
|
||||||
|
const autosave = createAutosave({
|
||||||
|
state,
|
||||||
|
getSavedVersion: () => savedVersion,
|
||||||
|
hasWritableSource: () => writable,
|
||||||
|
saveCurrentDocument: async (version) => {
|
||||||
|
await saveCurrentDocument(version)
|
||||||
|
savedVersion = version
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
autosave,
|
||||||
|
setWritable: (value: boolean) => {
|
||||||
|
writable = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('document autosave', () => {
|
||||||
|
test('skips saved versions and documents without writable sources', async () => {
|
||||||
|
const versions: number[] = []
|
||||||
|
const { state, autosave, setWritable } = setup(async (version) => {
|
||||||
|
versions.push(version)
|
||||||
|
})
|
||||||
|
|
||||||
|
await autosave.requestSave(0)
|
||||||
|
setWritable(false)
|
||||||
|
state.sceneVersion = 1
|
||||||
|
await autosave.requestSave(1)
|
||||||
|
expect(versions).toEqual([])
|
||||||
|
|
||||||
|
setWritable(true)
|
||||||
|
await autosave.requestSave(1)
|
||||||
|
state.sceneVersion = 0
|
||||||
|
await autosave.requestSave(0)
|
||||||
|
await autosave.requestSave(1)
|
||||||
|
expect(versions).toEqual([1])
|
||||||
|
autosave.disposeAutosave()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('coalesces 100 edits during an in-flight save into the latest version', async () => {
|
||||||
|
const firstSave = deferred()
|
||||||
|
const started: number[] = []
|
||||||
|
const { state, autosave } = setup(async (version) => {
|
||||||
|
started.push(version)
|
||||||
|
if (started.length === 1) await firstSave.promise
|
||||||
|
})
|
||||||
|
|
||||||
|
state.sceneVersion = 1
|
||||||
|
const pending = autosave.requestSave(1)
|
||||||
|
await Promise.resolve()
|
||||||
|
for (let version = 2; version <= 101; version++) {
|
||||||
|
state.sceneVersion = version
|
||||||
|
void autosave.requestSave(version)
|
||||||
|
}
|
||||||
|
firstSave.resolve()
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(started).toEqual([1, 101])
|
||||||
|
autosave.disposeAutosave()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retries the current version after a failed save', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
const { state, autosave } = setup(async () => {
|
||||||
|
attempts++
|
||||||
|
if (attempts === 1) throw new Error('write failed')
|
||||||
|
})
|
||||||
|
state.sceneVersion = 1
|
||||||
|
|
||||||
|
await expect(autosave.requestSave(1)).rejects.toThrow('write failed')
|
||||||
|
await autosave.requestSave(1)
|
||||||
|
|
||||||
|
expect(attempts).toBe(2)
|
||||||
|
autosave.disposeAutosave()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves a newer requested version when the active save fails', async () => {
|
||||||
|
const firstSave = deferred()
|
||||||
|
const started: number[] = []
|
||||||
|
const { state, autosave } = setup(async (version) => {
|
||||||
|
started.push(version)
|
||||||
|
if (version === 1) {
|
||||||
|
await firstSave.promise
|
||||||
|
throw new Error('write failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
state.sceneVersion = 1
|
||||||
|
const failed = autosave.requestSave(1)
|
||||||
|
await Promise.resolve()
|
||||||
|
state.sceneVersion = 2
|
||||||
|
void autosave.requestSave(2)
|
||||||
|
firstSave.resolve()
|
||||||
|
await expect(failed).rejects.toThrow('write failed')
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(started).toEqual([1, 2])
|
||||||
|
autosave.disposeAutosave()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -58,7 +58,7 @@ describe('document recovery controller', () => {
|
||||||
recovery.disposeRecovery()
|
recovery.disposeRecovery()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('coalesces concurrent changes to the latest scene version', async () => {
|
test('recovery coalesces 100 changes during encoding to the latest scene version', async () => {
|
||||||
let release: (() => void) | null = null
|
let release: (() => void) | null = null
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const { state, store, recovery } = setup(async () => {
|
const { state, store, recovery } = setup(async () => {
|
||||||
|
|
@ -73,8 +73,10 @@ describe('document recovery controller', () => {
|
||||||
state.sceneVersion = 1
|
state.sceneVersion = 1
|
||||||
const pending = recovery.persistNow()
|
const pending = recovery.persistNow()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
state.sceneVersion = 2
|
for (let version = 2; version <= 101; version++) {
|
||||||
void recovery.persistNow()
|
state.sceneVersion = version
|
||||||
|
void recovery.persistNow()
|
||||||
|
}
|
||||||
const releaseFirst = () => {
|
const releaseFirst = () => {
|
||||||
if (release) release()
|
if (release) release()
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +84,7 @@ describe('document recovery controller', () => {
|
||||||
await pending
|
await pending
|
||||||
|
|
||||||
expect(calls).toBe(2)
|
expect(calls).toBe(2)
|
||||||
expect((await store.read('recovery-1'))?.sceneVersion).toBe(2)
|
expect((await store.read('recovery-1'))?.sceneVersion).toBe(101)
|
||||||
recovery.disposeRecovery()
|
recovery.disposeRecovery()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue