fix: 关闭应用前干净关闭数据库 + 自愈仅启动早期触发

- 退出前 db.close() 确保 WAL/SSTable 干净落盘,避免渲染进程销毁时 flush 中断留下半写文件导致下次启动损坏
- 自愈(删损坏库/重载)仅限页面生命周期内首次初始化;运行中失败直接抛错,不再阻塞应用关闭流程
This commit is contained in:
thzxx
2026-08-09 17:05:31 +08:00
parent 9fcbec790d
commit 58c8a1a3a0
3 changed files with 82 additions and 24 deletions
+28
View File
@@ -0,0 +1,28 @@
import 'fake-indexeddb/auto'
import { AriaEngine } from '/mnt/d/CodeWorkspace/MarkLite/node_modules/@metona-team/metona-sqlark/dist/metona-sqlark.esm.js'
// 小 memtable 阈值 → 频繁 flush → 多个 SSTable + WAL
const engine1 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 4096, walEnabled: true })
await engine1.open('flushkill-test', 1)
await engine1.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, v: { type: 'number' } } })
// 写入大量数据触发多次 flush(写 WAL + 刷 SSTable
for (let i = 0; i < 3000; i++) {
await engine1.insert('t', [{ id: `k${i}`, v: i }])
}
console.log('S1 wrote 3000 rows')
// 不 close!直接丢弃实例(模拟应用被强制关闭/渲染进程被销毁)
// 重新打开同库
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 4096, walEnabled: true })
await engine2.open('flushkill-test', 1)
const tables = await engine2.getTableNames()
console.log('S2 tables:', JSON.stringify(tables))
if (tables.includes('t')) {
const rows = await engine2.find('t', { limit: 5 })
console.log('S2 sample rows:', JSON.stringify(rows))
console.log('REOPEN OK')
} else {
console.log('TABLE LOST')
}
await engine2.close()
+8 -1
View File
@@ -12,6 +12,7 @@ import { useDragDrop } from './hooks/useDragDrop'
import { useAutoSave } from './hooks/useAutoSave' import { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners' import { useIpcListeners } from './hooks/useIpcListeners'
import { MeToast } from './lib/toast' import { MeToast } from './lib/toast'
import { closeDatabase } from './db/schema'
import { Toolbar } from './components/Toolbar/Toolbar' import { Toolbar } from './components/Toolbar/Toolbar'
import { StatusBar } from './components/StatusBar' import { StatusBar } from './components/StatusBar'
import { TabBar } from './components/TabBar/TabBar' import { TabBar } from './components/TabBar/TabBar'
@@ -58,7 +59,13 @@ export function App() {
}) })
}, []) }, [])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB) // v0.6.0: 关闭前先保存标签快照,再干净关闭数据库(避免 flush 中断导致下次启动损坏)
const handleBeforeForceClose = useCallback(async () => {
await flushSaveToDB()
await closeDatabase()
}, [])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, handleBeforeForceClose)
useKeyboard(handleOpenFile, handleSave, handleSaveAs) useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners() useIpcListeners()
+46 -23
View File
@@ -79,18 +79,19 @@ const ACTIVE_TAB_COLUMNS: Record<string, ColumnDef> = {
} }
/** /**
* v0.6.0: MetonaSqlark 初始化 — 懒加载单例。
* create() 为异步,无法像 Dexie 那样模块顶层同步实例化;
* 首次调用时创建,之后复用同一 Promise。
*
* v0.6.0: 数据损坏自愈 — * v0.6.0: 数据损坏自愈 —
* AriaEngine 在异常退出(强杀/断电)时可能留下残缺的 SSTable 文件, * AriaEngine 在异常退出(强杀/断电)时可能留下残缺的 SSTable 文件,
* 重新打开解析块偏移会抛 RangeErroroffset is out of bounds)导致初始化失败。 * 重新打开解析块偏移会抛 RangeErroroffset is out of bounds)导致初始化失败。
* 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接) * 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接)
* → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。 * → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。
*
* 注意:自愈(删库/重载)只允许在页面生命周期内首次初始化时触发;
* 运行中(如应用关闭流程的 flushSaveToDBgetDb 失败时直接抛错,
* 绝不删库/重载,避免关闭流程被数据库操作阻塞。
*/ */
let dbPromise: Promise<MetonaSqlark> | null = null let dbPromise: Promise<MetonaSqlark> | null = null
let initFailedNotified = false let initFailedNotified = false
let healTriggered = false
// IndexedDBBackend 的内部库名 = `aria-${name}`sqlark 源码约定) // IndexedDBBackend 的内部库名 = `aria-${name}`sqlark 源码约定)
const DB_STORAGE_NAME = `aria-${DB_NAME}` const DB_STORAGE_NAME = `aria-${DB_NAME}`
@@ -144,28 +145,50 @@ async function createDatabase(): Promise<MetonaSqlark> {
} }
async function initDb(): Promise<MetonaSqlark> { async function initDb(): Promise<MetonaSqlark> {
await resetCorruptDatabaseIfNeeded() // 页面生命周期内仅首次初始化走自愈流程(删损坏库 + 失败重载)
try { if (!healTriggered) {
const db = await createDatabase() healTriggered = true
// 创建成功:清除重置标记(自愈完成) await resetCorruptDatabaseIfNeeded()
try { try {
localStorage.removeItem(RESET_PENDING_KEY) const db = await createDatabase()
} catch { // 创建成功:清除重置标记(自愈完成)
/* 忽略 */ try {
} localStorage.removeItem(RESET_PENDING_KEY)
return db } catch {
} catch (err) { /* 忽略 */
logError('数据库打开失败', err)
// 数据损坏自愈:首次失败 → 标记 + 重载页面(干净环境删除损坏库重建)
try {
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') {
localStorage.setItem(RESET_PENDING_KEY, '1')
window.location.reload()
} }
} catch { return db
/* 忽略 */ } catch (err) {
logError('数据库打开失败', err)
// 启动早期失败:标记 + 重载页面(干净环境删除损坏库重建)— 仅此一次
try {
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') {
localStorage.setItem(RESET_PENDING_KEY, '1')
window.location.reload()
}
} catch {
/* 忽略 */
}
throw err
} }
throw err }
// 运行中(自愈已尝试过):不再删库/重载,直接尝试创建,失败抛给调用方
return createDatabase()
}
/**
* v0.6.0: 关闭数据库 — 应用退出前调用,确保 WAL/SSTable 干净落盘,
* 避免渲染进程被销毁时 flush 中断留下半写文件导致下次启动损坏。
*/
export async function closeDatabase(): Promise<void> {
const current = dbPromise
dbPromise = null
if (!current) return
try {
const db = await current.catch(() => null)
if (db) await db.close()
} catch (err) {
logError('关闭数据库失败', err)
} }
} }