release: v0.4.3 — 关闭时序与后台任务加固(close 排空、失败不吞错、compaction 竞态修复)+ 提交先 WAL
This commit is contained in:
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to MetonaSqlark will be documented in this file.
|
||||
|
||||
## [0.4.3] - 2026-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- **close 后后台 compaction 吞错并在 backend 关闭后执行(P0)** — `scheduleCompact` 用 `setTimeout(0)` 调度:close 时未触发的定时器在 `backend.close()` 后执行,用已关闭的存储写(错误被吞);close 后立即 reopen 时旧闭包引用新 backend 交叉污染。改为 compaction 直接挂 flushChain 串行执行,close 的 `drainChain()` 排空全部级联任务后再关闭存储
|
||||
- **后台 flush/compaction 失败完全静默(P0)** — 吞错防死锁的同时记录 `lastBackgroundError`,显式 `flush()`/`close()` 时抛 `ARIA_BACKGROUND_ERROR`(消费一次),不再永久不可见
|
||||
- **prefetch 与 compaction 竞态丢数据(P0)** — 后台任务在链上动态增长,`prefetchRange/prefetchKeys` 单次 `await flushChain` 后 compaction 仍可能合并 levels,扫描时新 meta 缓存未命中 → 跳块丢数据(缓存驱逐场景 UPDATE/DELETE 少删行)。改为等待链稳定(`drainChain`)再预加载
|
||||
- **commitTransaction 提交顺序(P1)** — 先合并快照后写 WAL COMMIT:WAL 写失败时内存已提交但持久化缺失,崩溃重启丢失。改为先持久化 WAL COMMIT 再合并快照(WAL 始终领先,崩溃恢复一致)
|
||||
- **OPFS close 不等挂起写(P1)** — 写操作整体串行入队(内存写 + 快照 + 文件持久化),close/repair/clearAll 先等队列排空再释放目录句柄,避免 close 后挂起写读旧数据
|
||||
- **IndexedDBEngine close 活跃事务防御** — close 时自动回滚活跃事务(避免对已关闭连接操作报错)
|
||||
|
||||
### Changed
|
||||
|
||||
- 测试 944 → **950**(51 套件),新增 `tests/v044-hardening.test.ts`(6 条:close 后台任务排空、reopen 无污染、后台失败可见、链不卡死、WAL 提交顺序、OPFS close 等待)
|
||||
- 版本号升至 v0.4.3
|
||||
|
||||
---
|
||||
|
||||
## [0.4.2] - 2026-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# MetonaSqlark
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.4.2-blue?style=flat-square" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-0.4.3-blue?style=flat-square" alt="version">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
||||
<img src="https://img.shields.io/badge/coverage-84.2%25-brightgreen?style=flat-square" alt="coverage">
|
||||
<img src="https://img.shields.io/badge/tests-944%20passed-success?style=flat-square" alt="tests">
|
||||
<img src="https://img.shields.io/badge/tests-950%20passed-success?style=flat-square" alt="tests">
|
||||
</p>
|
||||
|
||||
> 基于 TypeScript 的**前端关系型数据库**,支持完整 SQL 查询、Query Builder 链式 API、与 **AriaEngine 自研页面式存储引擎**。
|
||||
@@ -25,10 +25,11 @@
|
||||
- 🔄 **事务回滚** — Memory/IndexedDB/Hybrid/Aria 四引擎事务原子性,自动回滚,MVCC 版本链接入读写路径
|
||||
- 🔗 **外键级联** — ON DELETE + ON UPDATE(CASCADE / SET NULL / RESTRICT)全引擎支持,支持更新主键(v0.4.2)
|
||||
- 🛡 **崩溃恢复自愈** — 残缺 SSTable 打开自动跳过、`db.repair()` 自愈、`db.clearAll()` 重置、迁移版本持久化(v0.4.2)
|
||||
- 🧵 **关闭时序与后台任务加固** — 后台 flush/compaction 串行入队(close 排空后才关闭存储,失败显式报告 `ARIA_BACKGROUND_ERROR`)、预加载等待链稳定(compaction 竞态修复)、事务提交先落 WAL 再合并快照(v0.4.3)
|
||||
- 🌲 **RB-Tree 完整实现** — 标准红黑树插入+删除修复,O(log n) 保证
|
||||
- ⚡ **性能优化** — SSTableReader 二分查找统一、IndexedDB 索引利用、crypto 实例化避免全局状态
|
||||
- 🌐 **浏览器兼容** — Chrome 80+ / Firefox 80+ / Safari 14+ / Edge 80+ / Node.js 16+
|
||||
- 🧪 **944 测试 · 84.2% 覆盖率** — 50 套件,生产级质量保证
|
||||
- 🧪 **950 测试 · 84.2% 覆盖率** — 51 套件,生产级质量保证
|
||||
|
||||
---
|
||||
|
||||
@@ -399,7 +400,7 @@ npm run typecheck # 类型检查
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 测试用例 | 944 |
|
||||
| 测试用例 | 950 |
|
||||
| 测试套件 | 51 |
|
||||
| 行覆盖率 | 84.2% |
|
||||
| SQL 关键字 | 36 |
|
||||
|
||||
Vendored
+177
-85
@@ -34,7 +34,7 @@ class DatabaseError extends Error {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
const VERSION = '0.4.2';
|
||||
const VERSION = '0.4.3';
|
||||
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
@@ -1021,6 +1021,13 @@ class IndexedDBEngine {
|
||||
}
|
||||
}
|
||||
async close() {
|
||||
// v0.4.3-fix: 活跃事务时先回滚(避免 commit 对已关闭连接报错)
|
||||
if (this.txActive) {
|
||||
try {
|
||||
await this.rollbackTransaction();
|
||||
}
|
||||
catch { /* 回滚失败不阻塞关闭 */ }
|
||||
}
|
||||
if (this.db) {
|
||||
this.db.onversionchange = null; // 清理监听器
|
||||
this.db.close();
|
||||
@@ -1607,6 +1614,18 @@ class OPFSEngine {
|
||||
this.dbName = '';
|
||||
// 运行时内存缓存(OPFS 文件读写有延迟)
|
||||
this.memoryCache = new MemoryEngine();
|
||||
/**
|
||||
* v0.4.3-fix: 写操作串行队列 — 内存写 + 快照 + 文件持久化整体排队执行,
|
||||
* close() 等待队列排空后再释放目录句柄(避免 close 后挂起写泄漏/读旧数据)。
|
||||
* 前一个操作失败不阻塞后续(错误仍返回给调用方)。
|
||||
*/
|
||||
this.opQueue = Promise.resolve();
|
||||
}
|
||||
/** 将写操作加入串行队列(快照在队列内取,始终最新) */
|
||||
enqueueOp(fn) {
|
||||
const run = this.opQueue.then(fn, fn);
|
||||
this.opQueue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
// ---- 生命周期 ----
|
||||
async open(dbName, version) {
|
||||
@@ -1620,6 +1639,11 @@ class OPFSEngine {
|
||||
await this.loadExistingTables();
|
||||
}
|
||||
async close() {
|
||||
// v0.4.3-fix: 等待所有挂起写操作完成(否则 close 后写仍在进行 → 重启读旧数据)
|
||||
try {
|
||||
await this.opQueue;
|
||||
}
|
||||
catch { /* 写失败已返回给调用方 */ }
|
||||
this.root = null;
|
||||
this.tablesDir = null;
|
||||
await this.memoryCache.close();
|
||||
@@ -1630,12 +1654,22 @@ class OPFSEngine {
|
||||
// ---- v0.4.2-fix: 自愈 / 重置 / 元数据 ----
|
||||
/** 自愈:重置内存缓存后从 OPFS 重新加载(单文件损坏不影响其他表) */
|
||||
async repair() {
|
||||
// v0.4.3-fix: 先等写队列排空(避免与挂起写竞态)
|
||||
try {
|
||||
await this.opQueue;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
await this.memoryCache.close();
|
||||
await this.memoryCache.open(this.dbName, 1);
|
||||
await this.loadExistingTables();
|
||||
}
|
||||
/** 清空全部数据与表结构(删除目录内全部文件) */
|
||||
async clearAll() {
|
||||
// v0.4.3-fix: 先等写队列排空
|
||||
try {
|
||||
await this.opQueue;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
await this.memoryCache.close();
|
||||
await this.memoryCache.open(this.dbName, 1);
|
||||
if (this.tablesDir) {
|
||||
@@ -1670,29 +1704,33 @@ class OPFSEngine {
|
||||
}
|
||||
// ---- 表管理 ----
|
||||
async createTable(schema) {
|
||||
await this.memoryCache.createTable(schema);
|
||||
// v0.4.2-fix: schema 持久化(此前仅写空数据文件 → 空表重启后消失、索引标记丢失)
|
||||
await this.setMeta(`schema_${schema.name}`, JSON.stringify(schema));
|
||||
// OPFS 中表以空 JSON 数组文件形式存在
|
||||
await this.writeTableData(schema.name, []);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.createTable(schema);
|
||||
// v0.4.2-fix: schema 持久化(此前仅写空数据文件 → 空表重启后消失、索引标记丢失)
|
||||
await this.setMeta(`schema_${schema.name}`, JSON.stringify(schema));
|
||||
// OPFS 中表以空 JSON 数组文件形式存在
|
||||
await this.writeTableData(schema.name, []);
|
||||
});
|
||||
}
|
||||
async dropTable(tableName) {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
// v0.4.2-fix: 清理 schema meta(否则重启恢复幽灵表)
|
||||
if (this.tablesDir) {
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`__metona_schema_${tableName}.meta`);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
// v0.4.2-fix: 清理 schema meta(否则重启恢复幽灵表)
|
||||
if (this.tablesDir) {
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`__metona_schema_${tableName}.meta`);
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`${tableName}.json`);
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`${tableName}.json`);
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async hasTable(tableName) {
|
||||
if (!this.tablesDir)
|
||||
@@ -1721,20 +1759,24 @@ class OPFSEngine {
|
||||
}
|
||||
/** v0.4.2-fix: 引擎级 ALTER TABLE — 内存 + schema 持久化 + 整表文件重写 */
|
||||
async alterTable(tableName, action, column) {
|
||||
await this.memoryCache.alterTable(tableName, action, column);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.alterTable(tableName, action, column);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
});
|
||||
}
|
||||
// ---- CRUD ----
|
||||
async insert(tableName, rows) {
|
||||
const pks = await this.memoryCache.insert(tableName, rows);
|
||||
// 持久化到 OPFS
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return pks;
|
||||
return this.enqueueOp(async () => {
|
||||
const pks = await this.memoryCache.insert(tableName, rows);
|
||||
// 持久化到 OPFS(快照在队列内取,始终最新)
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return pks;
|
||||
});
|
||||
}
|
||||
async find(tableName, query) {
|
||||
return this.memoryCache.find(tableName, query);
|
||||
@@ -1744,50 +1786,62 @@ class OPFSEngine {
|
||||
return this.memoryCache.findStream(tableName, query, onRow);
|
||||
}
|
||||
async update(tableName, query, updates) {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
return this.enqueueOp(async () => {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
});
|
||||
}
|
||||
async delete(tableName, query) {
|
||||
const count = await this.memoryCache.delete(tableName, query);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
return this.enqueueOp(async () => {
|
||||
const count = await this.memoryCache.delete(tableName, query);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
});
|
||||
}
|
||||
async count(tableName, query) {
|
||||
return this.memoryCache.count(tableName, query);
|
||||
}
|
||||
async clear(tableName) {
|
||||
await this.memoryCache.clear(tableName);
|
||||
await this.writeTableData(tableName, []);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.clear(tableName);
|
||||
await this.writeTableData(tableName, []);
|
||||
});
|
||||
}
|
||||
// ---- 动态索引(v0.3.0) ----
|
||||
async createIndex(tableName, column, unique) {
|
||||
await this.memoryCache.createIndex(tableName, column, unique);
|
||||
// v0.4.2-fix: 索引标记持久化(重启后索引结构恢复)
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.createIndex(tableName, column, unique);
|
||||
// v0.4.2-fix: 索引标记持久化(重启后索引结构恢复)
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
});
|
||||
}
|
||||
async dropIndex(tableName, column, indexName) {
|
||||
await this.memoryCache.dropIndex(tableName, column, indexName);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.dropIndex(tableName, column, indexName);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
});
|
||||
}
|
||||
// ---- 事务 ----
|
||||
async beginTransaction() {
|
||||
await this.memoryCache.beginTransaction();
|
||||
}
|
||||
async commitTransaction() {
|
||||
await this.memoryCache.commitTransaction();
|
||||
// 将内存数据刷到 OPFS
|
||||
const tableNames = await this.memoryCache.getTableNames();
|
||||
for (const tableName of tableNames) {
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
}
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.commitTransaction();
|
||||
// 将内存数据刷到 OPFS
|
||||
const tableNames = await this.memoryCache.getTableNames();
|
||||
for (const tableName of tableNames) {
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
async rollbackTransaction() {
|
||||
await this.memoryCache.rollbackTransaction();
|
||||
@@ -1800,6 +1854,7 @@ class OPFSEngine {
|
||||
return this.tablesDir;
|
||||
}
|
||||
async writeTableData(tableName, data) {
|
||||
// 由 enqueueOp 串行化调用,此处直接写文件
|
||||
const dir = this.ensureDir();
|
||||
const fileName = `${tableName}.json`;
|
||||
const fileHandle = await dir.getFileHandle(fileName, { create: true });
|
||||
@@ -3274,6 +3329,11 @@ class LSM {
|
||||
this.compacting = false; // 防止重复触发 compaction
|
||||
/** 串行化 flush/compaction 链:保证持久化顺序与 id 分配顺序一致 */
|
||||
this.flushChain = Promise.resolve();
|
||||
/**
|
||||
* v0.4.3-fix: 最近一次后台 flush/compaction 失败。
|
||||
* 后台失败不卡死链(吞错防死锁),但在显式 flush()/close() 时报告(不静默)。
|
||||
*/
|
||||
this.lastBackgroundError = null;
|
||||
this.memtableSizeThreshold = config.memtableSizeThreshold ?? DEFAULT_MEMTABLE_SIZE;
|
||||
this.memtable = new MemTable(this.memtableSizeThreshold);
|
||||
this.levelSizeMultiplier = config.levelSizeMultiplier ?? DEFAULT_LEVEL_SIZE_MULTIPLIER;
|
||||
@@ -3371,11 +3431,26 @@ class LSM {
|
||||
return this.flushChain
|
||||
.then(task)
|
||||
.catch((error) => {
|
||||
// v0.4.3-fix: 记录失败(flush()/close() 时报告),不再完全静默吞错
|
||||
this.lastBackgroundError = error;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine LSM] background flush/compaction failed:', error);
|
||||
// catch 返回 undefined → 链恢复为 resolved,后续任务继续
|
||||
});
|
||||
}
|
||||
/**
|
||||
* v0.4.3-fix: 排空后台链 — 循环等待 flushChain 直到稳定。
|
||||
* 任务完成时可能级联调度新任务(compaction 多级触发),单次 await 等不到。
|
||||
* close/flush/clear 必须等待全部后台任务完成后才能安全关闭底层存储。
|
||||
*/
|
||||
async drainChain() {
|
||||
while (true) {
|
||||
const chain = this.flushChain;
|
||||
await chain;
|
||||
if (this.flushChain === chain)
|
||||
return;
|
||||
}
|
||||
}
|
||||
/** 将指定 Immutable MemTable 刷盘为 SSTable(id 由 store 按命名空间分配) */
|
||||
async flushImmutableAsync(frozen) {
|
||||
const entries = frozen.getAllEntries();
|
||||
@@ -3415,27 +3490,27 @@ class LSM {
|
||||
this.scheduleCompact(0);
|
||||
}
|
||||
}
|
||||
/** 异步调度 compaction,使用 setTimeout 分片执行 */
|
||||
/**
|
||||
* 异步调度 compaction。
|
||||
* v0.4.3-fix: 去掉 setTimeout 分片 — 此前未触发的定时器在 close 后执行,
|
||||
* 用已关闭的 backend 写存储(错误被吞),或 close 后 reopen 时旧闭包引用新 backend 交叉污染。
|
||||
* 现在直接挂在 flushChain 上:串行执行、close 的 drainChain 能等到全部完成。
|
||||
*/
|
||||
scheduleCompact(level) {
|
||||
if (level >= MAX_LSM_LEVELS - 1 || this.compacting)
|
||||
return;
|
||||
this.compacting = true;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.flushChain = this.enqueueOnChain(() => this.compactLevelAsync(level));
|
||||
this.flushChain = this.enqueueOnChain(() => this.compactLevelAsync(level).finally(() => {
|
||||
this.compacting = false;
|
||||
// 连续触发:如果 compaction 后仍然超标,继续调度
|
||||
if (this.levels[level].length >= 4) {
|
||||
this.scheduleCompact(level);
|
||||
}
|
||||
finally {
|
||||
this.compacting = false;
|
||||
// 连续触发:如果 compaction 后仍然超标,继续调度
|
||||
if (this.levels[level].length >= 4) {
|
||||
this.scheduleCompact(level);
|
||||
}
|
||||
// 检查下一级是否需要 compaction
|
||||
if (level + 1 < MAX_LSM_LEVELS - 1 && this.levels[level + 1].length >= 4) {
|
||||
this.scheduleCompact(level + 1);
|
||||
}
|
||||
// 检查下一级是否需要 compaction
|
||||
if (level + 1 < MAX_LSM_LEVELS - 1 && this.levels[level + 1].length >= 4) {
|
||||
this.scheduleCompact(level + 1);
|
||||
}
|
||||
}, 0);
|
||||
}));
|
||||
}
|
||||
/** 背压场景下排队 compaction(写入路径调用) */
|
||||
enqueueCompact(level) {
|
||||
@@ -3451,6 +3526,8 @@ class LSM {
|
||||
}
|
||||
}).catch((error) => {
|
||||
this.compacting = false;
|
||||
// v0.4.3-fix: 记录失败(flush()/close() 时报告)
|
||||
this.lastBackgroundError = error;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine LSM] background compaction failed:', error);
|
||||
});
|
||||
@@ -3461,10 +3538,11 @@ class LSM {
|
||||
/**
|
||||
* 预加载指定 key 范围内可能命中的所有 SSTable 到缓存。
|
||||
* 在同步扫描/查找之前调用,保证 loadSSTableReader 不会因缓存未命中而返回 null。
|
||||
* 先等待 flush 链完成:避免 flush 的缓存裁剪与预加载竞争(驱逐刚加载的 SSTable)。
|
||||
* v0.4.3-fix: 等待链稳定(drainChain)— 后台 flush/compaction 在链上动态增长,
|
||||
* 单次 await 后 compaction 仍可能合并 levels,导致扫描时新 meta 缓存未命中而跳块丢数据。
|
||||
*/
|
||||
async prefetchRange(startKey, endKey) {
|
||||
await this.flushChain;
|
||||
await this.drainChain();
|
||||
this.trimCache();
|
||||
const toLoad = [];
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -3483,7 +3561,8 @@ class LSM {
|
||||
async prefetchKeys(keys) {
|
||||
if (keys.length === 0)
|
||||
return;
|
||||
await this.flushChain;
|
||||
// v0.4.3-fix: 等待链稳定(同 prefetchRange,防 compaction 竞态丢数据)
|
||||
await this.drainChain();
|
||||
this.trimCache();
|
||||
const toLoad = new Set();
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -3674,8 +3753,14 @@ class LSM {
|
||||
}
|
||||
/** 等待所有排队的 flush/compaction 完成,并将剩余数据刷盘 */
|
||||
async flush() {
|
||||
// 等待链上已排队的 flush/compaction
|
||||
await this.flushChain;
|
||||
// v0.4.3-fix: 报告后台失败(消费一次,不永久吞错)
|
||||
if (this.lastBackgroundError !== null) {
|
||||
const error = this.lastBackgroundError;
|
||||
this.lastBackgroundError = null;
|
||||
throw new DatabaseError('AriaEngine background flush/compaction failed (data may be inconsistent)', 'ARIA_BACKGROUND_ERROR', error);
|
||||
}
|
||||
// v0.4.3-fix: 循环等待级联任务(flush 完成可能触发新的 compaction)
|
||||
await this.drainChain();
|
||||
// 若仍有 frozen 数据未刷盘,在链尾追加
|
||||
if (this.immutableMemtable) {
|
||||
const frozen = this.immutableMemtable;
|
||||
@@ -3688,8 +3773,12 @@ class LSM {
|
||||
await this.flushImmutableAsync(frozen);
|
||||
}
|
||||
this.frozenMemtables = [];
|
||||
// 刷盘完成后级联调度可能触发 compaction → 排空到稳定
|
||||
await this.drainChain();
|
||||
}
|
||||
async clear() {
|
||||
// v0.4.3-fix: 清空前排空后台任务(避免 compaction 在清空后写回残留 meta/数据)
|
||||
await this.drainChain();
|
||||
this.memtable.clear();
|
||||
this.immutableMemtable = null;
|
||||
this.frozenMemtables = [];
|
||||
@@ -6304,6 +6393,16 @@ class AriaEngine {
|
||||
async commitTransaction() {
|
||||
if (!this.currentTxnId)
|
||||
throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
// v0.4.3-fix: 先持久化 WAL COMMIT,再合并快照到 LSM —
|
||||
// 崩溃在 WAL 提交后、快照合并前:恢复时 WAL 重放数据,重启一致;
|
||||
// 崩溃在 WAL 提交前:commitTransaction 尚未返回,事务视为未提交(可回滚)
|
||||
await this.wal.append({
|
||||
type: WALRecordType.COMMIT,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
await this.wal.flush();
|
||||
if (this.txnSnapshot) {
|
||||
for (const [key, value] of this.txnSnapshot) {
|
||||
if (value.__txn_deleted) {
|
||||
@@ -6315,15 +6414,8 @@ class AriaEngine {
|
||||
}
|
||||
}
|
||||
this.mvcc.commitTransaction(this.currentTxnId);
|
||||
await this.wal.append({
|
||||
type: WALRecordType.COMMIT,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
this.currentTxnId = null;
|
||||
this.txnSnapshot = null;
|
||||
await this.wal.flush();
|
||||
}
|
||||
async rollbackTransaction() {
|
||||
if (!this.currentTxnId)
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+9
-1
@@ -118,7 +118,7 @@ interface MetonaPlugin {
|
||||
/** 销毁 */
|
||||
destroy(): void;
|
||||
}
|
||||
declare const VERSION = "0.4.2";
|
||||
declare const VERSION = "0.4.3";
|
||||
|
||||
/**
|
||||
* metona-sqlark Plugin — 插件系统
|
||||
@@ -853,6 +853,14 @@ declare class OPFSEngine implements IStorageEngine {
|
||||
private tablesDir;
|
||||
private dbName;
|
||||
private memoryCache;
|
||||
/**
|
||||
* v0.4.3-fix: 写操作串行队列 — 内存写 + 快照 + 文件持久化整体排队执行,
|
||||
* close() 等待队列排空后再释放目录句柄(避免 close 后挂起写泄漏/读旧数据)。
|
||||
* 前一个操作失败不阻塞后续(错误仍返回给调用方)。
|
||||
*/
|
||||
private opQueue;
|
||||
/** 将写操作加入串行队列(快照在队列内取,始终最新) */
|
||||
private enqueueOp;
|
||||
open(dbName: string, version: number): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
isOpen(): boolean;
|
||||
|
||||
Vendored
+177
-85
@@ -30,7 +30,7 @@ class DatabaseError extends Error {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
const VERSION = '0.4.2';
|
||||
const VERSION = '0.4.3';
|
||||
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
@@ -1017,6 +1017,13 @@ class IndexedDBEngine {
|
||||
}
|
||||
}
|
||||
async close() {
|
||||
// v0.4.3-fix: 活跃事务时先回滚(避免 commit 对已关闭连接报错)
|
||||
if (this.txActive) {
|
||||
try {
|
||||
await this.rollbackTransaction();
|
||||
}
|
||||
catch { /* 回滚失败不阻塞关闭 */ }
|
||||
}
|
||||
if (this.db) {
|
||||
this.db.onversionchange = null; // 清理监听器
|
||||
this.db.close();
|
||||
@@ -1603,6 +1610,18 @@ class OPFSEngine {
|
||||
this.dbName = '';
|
||||
// 运行时内存缓存(OPFS 文件读写有延迟)
|
||||
this.memoryCache = new MemoryEngine();
|
||||
/**
|
||||
* v0.4.3-fix: 写操作串行队列 — 内存写 + 快照 + 文件持久化整体排队执行,
|
||||
* close() 等待队列排空后再释放目录句柄(避免 close 后挂起写泄漏/读旧数据)。
|
||||
* 前一个操作失败不阻塞后续(错误仍返回给调用方)。
|
||||
*/
|
||||
this.opQueue = Promise.resolve();
|
||||
}
|
||||
/** 将写操作加入串行队列(快照在队列内取,始终最新) */
|
||||
enqueueOp(fn) {
|
||||
const run = this.opQueue.then(fn, fn);
|
||||
this.opQueue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
// ---- 生命周期 ----
|
||||
async open(dbName, version) {
|
||||
@@ -1616,6 +1635,11 @@ class OPFSEngine {
|
||||
await this.loadExistingTables();
|
||||
}
|
||||
async close() {
|
||||
// v0.4.3-fix: 等待所有挂起写操作完成(否则 close 后写仍在进行 → 重启读旧数据)
|
||||
try {
|
||||
await this.opQueue;
|
||||
}
|
||||
catch { /* 写失败已返回给调用方 */ }
|
||||
this.root = null;
|
||||
this.tablesDir = null;
|
||||
await this.memoryCache.close();
|
||||
@@ -1626,12 +1650,22 @@ class OPFSEngine {
|
||||
// ---- v0.4.2-fix: 自愈 / 重置 / 元数据 ----
|
||||
/** 自愈:重置内存缓存后从 OPFS 重新加载(单文件损坏不影响其他表) */
|
||||
async repair() {
|
||||
// v0.4.3-fix: 先等写队列排空(避免与挂起写竞态)
|
||||
try {
|
||||
await this.opQueue;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
await this.memoryCache.close();
|
||||
await this.memoryCache.open(this.dbName, 1);
|
||||
await this.loadExistingTables();
|
||||
}
|
||||
/** 清空全部数据与表结构(删除目录内全部文件) */
|
||||
async clearAll() {
|
||||
// v0.4.3-fix: 先等写队列排空
|
||||
try {
|
||||
await this.opQueue;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
await this.memoryCache.close();
|
||||
await this.memoryCache.open(this.dbName, 1);
|
||||
if (this.tablesDir) {
|
||||
@@ -1666,29 +1700,33 @@ class OPFSEngine {
|
||||
}
|
||||
// ---- 表管理 ----
|
||||
async createTable(schema) {
|
||||
await this.memoryCache.createTable(schema);
|
||||
// v0.4.2-fix: schema 持久化(此前仅写空数据文件 → 空表重启后消失、索引标记丢失)
|
||||
await this.setMeta(`schema_${schema.name}`, JSON.stringify(schema));
|
||||
// OPFS 中表以空 JSON 数组文件形式存在
|
||||
await this.writeTableData(schema.name, []);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.createTable(schema);
|
||||
// v0.4.2-fix: schema 持久化(此前仅写空数据文件 → 空表重启后消失、索引标记丢失)
|
||||
await this.setMeta(`schema_${schema.name}`, JSON.stringify(schema));
|
||||
// OPFS 中表以空 JSON 数组文件形式存在
|
||||
await this.writeTableData(schema.name, []);
|
||||
});
|
||||
}
|
||||
async dropTable(tableName) {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
// v0.4.2-fix: 清理 schema meta(否则重启恢复幽灵表)
|
||||
if (this.tablesDir) {
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`__metona_schema_${tableName}.meta`);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
// v0.4.2-fix: 清理 schema meta(否则重启恢复幽灵表)
|
||||
if (this.tablesDir) {
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`__metona_schema_${tableName}.meta`);
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`${tableName}.json`);
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`${tableName}.json`);
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async hasTable(tableName) {
|
||||
if (!this.tablesDir)
|
||||
@@ -1717,20 +1755,24 @@ class OPFSEngine {
|
||||
}
|
||||
/** v0.4.2-fix: 引擎级 ALTER TABLE — 内存 + schema 持久化 + 整表文件重写 */
|
||||
async alterTable(tableName, action, column) {
|
||||
await this.memoryCache.alterTable(tableName, action, column);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.alterTable(tableName, action, column);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
});
|
||||
}
|
||||
// ---- CRUD ----
|
||||
async insert(tableName, rows) {
|
||||
const pks = await this.memoryCache.insert(tableName, rows);
|
||||
// 持久化到 OPFS
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return pks;
|
||||
return this.enqueueOp(async () => {
|
||||
const pks = await this.memoryCache.insert(tableName, rows);
|
||||
// 持久化到 OPFS(快照在队列内取,始终最新)
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return pks;
|
||||
});
|
||||
}
|
||||
async find(tableName, query) {
|
||||
return this.memoryCache.find(tableName, query);
|
||||
@@ -1740,50 +1782,62 @@ class OPFSEngine {
|
||||
return this.memoryCache.findStream(tableName, query, onRow);
|
||||
}
|
||||
async update(tableName, query, updates) {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
return this.enqueueOp(async () => {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
});
|
||||
}
|
||||
async delete(tableName, query) {
|
||||
const count = await this.memoryCache.delete(tableName, query);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
return this.enqueueOp(async () => {
|
||||
const count = await this.memoryCache.delete(tableName, query);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
});
|
||||
}
|
||||
async count(tableName, query) {
|
||||
return this.memoryCache.count(tableName, query);
|
||||
}
|
||||
async clear(tableName) {
|
||||
await this.memoryCache.clear(tableName);
|
||||
await this.writeTableData(tableName, []);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.clear(tableName);
|
||||
await this.writeTableData(tableName, []);
|
||||
});
|
||||
}
|
||||
// ---- 动态索引(v0.3.0) ----
|
||||
async createIndex(tableName, column, unique) {
|
||||
await this.memoryCache.createIndex(tableName, column, unique);
|
||||
// v0.4.2-fix: 索引标记持久化(重启后索引结构恢复)
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.createIndex(tableName, column, unique);
|
||||
// v0.4.2-fix: 索引标记持久化(重启后索引结构恢复)
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
});
|
||||
}
|
||||
async dropIndex(tableName, column, indexName) {
|
||||
await this.memoryCache.dropIndex(tableName, column, indexName);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.dropIndex(tableName, column, indexName);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
});
|
||||
}
|
||||
// ---- 事务 ----
|
||||
async beginTransaction() {
|
||||
await this.memoryCache.beginTransaction();
|
||||
}
|
||||
async commitTransaction() {
|
||||
await this.memoryCache.commitTransaction();
|
||||
// 将内存数据刷到 OPFS
|
||||
const tableNames = await this.memoryCache.getTableNames();
|
||||
for (const tableName of tableNames) {
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
}
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.commitTransaction();
|
||||
// 将内存数据刷到 OPFS
|
||||
const tableNames = await this.memoryCache.getTableNames();
|
||||
for (const tableName of tableNames) {
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
async rollbackTransaction() {
|
||||
await this.memoryCache.rollbackTransaction();
|
||||
@@ -1796,6 +1850,7 @@ class OPFSEngine {
|
||||
return this.tablesDir;
|
||||
}
|
||||
async writeTableData(tableName, data) {
|
||||
// 由 enqueueOp 串行化调用,此处直接写文件
|
||||
const dir = this.ensureDir();
|
||||
const fileName = `${tableName}.json`;
|
||||
const fileHandle = await dir.getFileHandle(fileName, { create: true });
|
||||
@@ -3270,6 +3325,11 @@ class LSM {
|
||||
this.compacting = false; // 防止重复触发 compaction
|
||||
/** 串行化 flush/compaction 链:保证持久化顺序与 id 分配顺序一致 */
|
||||
this.flushChain = Promise.resolve();
|
||||
/**
|
||||
* v0.4.3-fix: 最近一次后台 flush/compaction 失败。
|
||||
* 后台失败不卡死链(吞错防死锁),但在显式 flush()/close() 时报告(不静默)。
|
||||
*/
|
||||
this.lastBackgroundError = null;
|
||||
this.memtableSizeThreshold = config.memtableSizeThreshold ?? DEFAULT_MEMTABLE_SIZE;
|
||||
this.memtable = new MemTable(this.memtableSizeThreshold);
|
||||
this.levelSizeMultiplier = config.levelSizeMultiplier ?? DEFAULT_LEVEL_SIZE_MULTIPLIER;
|
||||
@@ -3367,11 +3427,26 @@ class LSM {
|
||||
return this.flushChain
|
||||
.then(task)
|
||||
.catch((error) => {
|
||||
// v0.4.3-fix: 记录失败(flush()/close() 时报告),不再完全静默吞错
|
||||
this.lastBackgroundError = error;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine LSM] background flush/compaction failed:', error);
|
||||
// catch 返回 undefined → 链恢复为 resolved,后续任务继续
|
||||
});
|
||||
}
|
||||
/**
|
||||
* v0.4.3-fix: 排空后台链 — 循环等待 flushChain 直到稳定。
|
||||
* 任务完成时可能级联调度新任务(compaction 多级触发),单次 await 等不到。
|
||||
* close/flush/clear 必须等待全部后台任务完成后才能安全关闭底层存储。
|
||||
*/
|
||||
async drainChain() {
|
||||
while (true) {
|
||||
const chain = this.flushChain;
|
||||
await chain;
|
||||
if (this.flushChain === chain)
|
||||
return;
|
||||
}
|
||||
}
|
||||
/** 将指定 Immutable MemTable 刷盘为 SSTable(id 由 store 按命名空间分配) */
|
||||
async flushImmutableAsync(frozen) {
|
||||
const entries = frozen.getAllEntries();
|
||||
@@ -3411,27 +3486,27 @@ class LSM {
|
||||
this.scheduleCompact(0);
|
||||
}
|
||||
}
|
||||
/** 异步调度 compaction,使用 setTimeout 分片执行 */
|
||||
/**
|
||||
* 异步调度 compaction。
|
||||
* v0.4.3-fix: 去掉 setTimeout 分片 — 此前未触发的定时器在 close 后执行,
|
||||
* 用已关闭的 backend 写存储(错误被吞),或 close 后 reopen 时旧闭包引用新 backend 交叉污染。
|
||||
* 现在直接挂在 flushChain 上:串行执行、close 的 drainChain 能等到全部完成。
|
||||
*/
|
||||
scheduleCompact(level) {
|
||||
if (level >= MAX_LSM_LEVELS - 1 || this.compacting)
|
||||
return;
|
||||
this.compacting = true;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.flushChain = this.enqueueOnChain(() => this.compactLevelAsync(level));
|
||||
this.flushChain = this.enqueueOnChain(() => this.compactLevelAsync(level).finally(() => {
|
||||
this.compacting = false;
|
||||
// 连续触发:如果 compaction 后仍然超标,继续调度
|
||||
if (this.levels[level].length >= 4) {
|
||||
this.scheduleCompact(level);
|
||||
}
|
||||
finally {
|
||||
this.compacting = false;
|
||||
// 连续触发:如果 compaction 后仍然超标,继续调度
|
||||
if (this.levels[level].length >= 4) {
|
||||
this.scheduleCompact(level);
|
||||
}
|
||||
// 检查下一级是否需要 compaction
|
||||
if (level + 1 < MAX_LSM_LEVELS - 1 && this.levels[level + 1].length >= 4) {
|
||||
this.scheduleCompact(level + 1);
|
||||
}
|
||||
// 检查下一级是否需要 compaction
|
||||
if (level + 1 < MAX_LSM_LEVELS - 1 && this.levels[level + 1].length >= 4) {
|
||||
this.scheduleCompact(level + 1);
|
||||
}
|
||||
}, 0);
|
||||
}));
|
||||
}
|
||||
/** 背压场景下排队 compaction(写入路径调用) */
|
||||
enqueueCompact(level) {
|
||||
@@ -3447,6 +3522,8 @@ class LSM {
|
||||
}
|
||||
}).catch((error) => {
|
||||
this.compacting = false;
|
||||
// v0.4.3-fix: 记录失败(flush()/close() 时报告)
|
||||
this.lastBackgroundError = error;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine LSM] background compaction failed:', error);
|
||||
});
|
||||
@@ -3457,10 +3534,11 @@ class LSM {
|
||||
/**
|
||||
* 预加载指定 key 范围内可能命中的所有 SSTable 到缓存。
|
||||
* 在同步扫描/查找之前调用,保证 loadSSTableReader 不会因缓存未命中而返回 null。
|
||||
* 先等待 flush 链完成:避免 flush 的缓存裁剪与预加载竞争(驱逐刚加载的 SSTable)。
|
||||
* v0.4.3-fix: 等待链稳定(drainChain)— 后台 flush/compaction 在链上动态增长,
|
||||
* 单次 await 后 compaction 仍可能合并 levels,导致扫描时新 meta 缓存未命中而跳块丢数据。
|
||||
*/
|
||||
async prefetchRange(startKey, endKey) {
|
||||
await this.flushChain;
|
||||
await this.drainChain();
|
||||
this.trimCache();
|
||||
const toLoad = [];
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -3479,7 +3557,8 @@ class LSM {
|
||||
async prefetchKeys(keys) {
|
||||
if (keys.length === 0)
|
||||
return;
|
||||
await this.flushChain;
|
||||
// v0.4.3-fix: 等待链稳定(同 prefetchRange,防 compaction 竞态丢数据)
|
||||
await this.drainChain();
|
||||
this.trimCache();
|
||||
const toLoad = new Set();
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -3670,8 +3749,14 @@ class LSM {
|
||||
}
|
||||
/** 等待所有排队的 flush/compaction 完成,并将剩余数据刷盘 */
|
||||
async flush() {
|
||||
// 等待链上已排队的 flush/compaction
|
||||
await this.flushChain;
|
||||
// v0.4.3-fix: 报告后台失败(消费一次,不永久吞错)
|
||||
if (this.lastBackgroundError !== null) {
|
||||
const error = this.lastBackgroundError;
|
||||
this.lastBackgroundError = null;
|
||||
throw new DatabaseError('AriaEngine background flush/compaction failed (data may be inconsistent)', 'ARIA_BACKGROUND_ERROR', error);
|
||||
}
|
||||
// v0.4.3-fix: 循环等待级联任务(flush 完成可能触发新的 compaction)
|
||||
await this.drainChain();
|
||||
// 若仍有 frozen 数据未刷盘,在链尾追加
|
||||
if (this.immutableMemtable) {
|
||||
const frozen = this.immutableMemtable;
|
||||
@@ -3684,8 +3769,12 @@ class LSM {
|
||||
await this.flushImmutableAsync(frozen);
|
||||
}
|
||||
this.frozenMemtables = [];
|
||||
// 刷盘完成后级联调度可能触发 compaction → 排空到稳定
|
||||
await this.drainChain();
|
||||
}
|
||||
async clear() {
|
||||
// v0.4.3-fix: 清空前排空后台任务(避免 compaction 在清空后写回残留 meta/数据)
|
||||
await this.drainChain();
|
||||
this.memtable.clear();
|
||||
this.immutableMemtable = null;
|
||||
this.frozenMemtables = [];
|
||||
@@ -6300,6 +6389,16 @@ class AriaEngine {
|
||||
async commitTransaction() {
|
||||
if (!this.currentTxnId)
|
||||
throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
// v0.4.3-fix: 先持久化 WAL COMMIT,再合并快照到 LSM —
|
||||
// 崩溃在 WAL 提交后、快照合并前:恢复时 WAL 重放数据,重启一致;
|
||||
// 崩溃在 WAL 提交前:commitTransaction 尚未返回,事务视为未提交(可回滚)
|
||||
await this.wal.append({
|
||||
type: WALRecordType.COMMIT,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
await this.wal.flush();
|
||||
if (this.txnSnapshot) {
|
||||
for (const [key, value] of this.txnSnapshot) {
|
||||
if (value.__txn_deleted) {
|
||||
@@ -6311,15 +6410,8 @@ class AriaEngine {
|
||||
}
|
||||
}
|
||||
this.mvcc.commitTransaction(this.currentTxnId);
|
||||
await this.wal.append({
|
||||
type: WALRecordType.COMMIT,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
this.currentTxnId = null;
|
||||
this.txnSnapshot = null;
|
||||
await this.wal.flush();
|
||||
}
|
||||
async rollbackTransaction() {
|
||||
if (!this.currentTxnId)
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+177
-85
@@ -36,7 +36,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
const VERSION = '0.4.2';
|
||||
const VERSION = '0.4.3';
|
||||
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
@@ -1023,6 +1023,13 @@
|
||||
}
|
||||
}
|
||||
async close() {
|
||||
// v0.4.3-fix: 活跃事务时先回滚(避免 commit 对已关闭连接报错)
|
||||
if (this.txActive) {
|
||||
try {
|
||||
await this.rollbackTransaction();
|
||||
}
|
||||
catch { /* 回滚失败不阻塞关闭 */ }
|
||||
}
|
||||
if (this.db) {
|
||||
this.db.onversionchange = null; // 清理监听器
|
||||
this.db.close();
|
||||
@@ -1609,6 +1616,18 @@
|
||||
this.dbName = '';
|
||||
// 运行时内存缓存(OPFS 文件读写有延迟)
|
||||
this.memoryCache = new MemoryEngine();
|
||||
/**
|
||||
* v0.4.3-fix: 写操作串行队列 — 内存写 + 快照 + 文件持久化整体排队执行,
|
||||
* close() 等待队列排空后再释放目录句柄(避免 close 后挂起写泄漏/读旧数据)。
|
||||
* 前一个操作失败不阻塞后续(错误仍返回给调用方)。
|
||||
*/
|
||||
this.opQueue = Promise.resolve();
|
||||
}
|
||||
/** 将写操作加入串行队列(快照在队列内取,始终最新) */
|
||||
enqueueOp(fn) {
|
||||
const run = this.opQueue.then(fn, fn);
|
||||
this.opQueue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
// ---- 生命周期 ----
|
||||
async open(dbName, version) {
|
||||
@@ -1622,6 +1641,11 @@
|
||||
await this.loadExistingTables();
|
||||
}
|
||||
async close() {
|
||||
// v0.4.3-fix: 等待所有挂起写操作完成(否则 close 后写仍在进行 → 重启读旧数据)
|
||||
try {
|
||||
await this.opQueue;
|
||||
}
|
||||
catch { /* 写失败已返回给调用方 */ }
|
||||
this.root = null;
|
||||
this.tablesDir = null;
|
||||
await this.memoryCache.close();
|
||||
@@ -1632,12 +1656,22 @@
|
||||
// ---- v0.4.2-fix: 自愈 / 重置 / 元数据 ----
|
||||
/** 自愈:重置内存缓存后从 OPFS 重新加载(单文件损坏不影响其他表) */
|
||||
async repair() {
|
||||
// v0.4.3-fix: 先等写队列排空(避免与挂起写竞态)
|
||||
try {
|
||||
await this.opQueue;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
await this.memoryCache.close();
|
||||
await this.memoryCache.open(this.dbName, 1);
|
||||
await this.loadExistingTables();
|
||||
}
|
||||
/** 清空全部数据与表结构(删除目录内全部文件) */
|
||||
async clearAll() {
|
||||
// v0.4.3-fix: 先等写队列排空
|
||||
try {
|
||||
await this.opQueue;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
await this.memoryCache.close();
|
||||
await this.memoryCache.open(this.dbName, 1);
|
||||
if (this.tablesDir) {
|
||||
@@ -1672,29 +1706,33 @@
|
||||
}
|
||||
// ---- 表管理 ----
|
||||
async createTable(schema) {
|
||||
await this.memoryCache.createTable(schema);
|
||||
// v0.4.2-fix: schema 持久化(此前仅写空数据文件 → 空表重启后消失、索引标记丢失)
|
||||
await this.setMeta(`schema_${schema.name}`, JSON.stringify(schema));
|
||||
// OPFS 中表以空 JSON 数组文件形式存在
|
||||
await this.writeTableData(schema.name, []);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.createTable(schema);
|
||||
// v0.4.2-fix: schema 持久化(此前仅写空数据文件 → 空表重启后消失、索引标记丢失)
|
||||
await this.setMeta(`schema_${schema.name}`, JSON.stringify(schema));
|
||||
// OPFS 中表以空 JSON 数组文件形式存在
|
||||
await this.writeTableData(schema.name, []);
|
||||
});
|
||||
}
|
||||
async dropTable(tableName) {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
// v0.4.2-fix: 清理 schema meta(否则重启恢复幽灵表)
|
||||
if (this.tablesDir) {
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`__metona_schema_${tableName}.meta`);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
// v0.4.2-fix: 清理 schema meta(否则重启恢复幽灵表)
|
||||
if (this.tablesDir) {
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`__metona_schema_${tableName}.meta`);
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`${tableName}.json`);
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`${tableName}.json`);
|
||||
}
|
||||
catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async hasTable(tableName) {
|
||||
if (!this.tablesDir)
|
||||
@@ -1723,20 +1761,24 @@
|
||||
}
|
||||
/** v0.4.2-fix: 引擎级 ALTER TABLE — 内存 + schema 持久化 + 整表文件重写 */
|
||||
async alterTable(tableName, action, column) {
|
||||
await this.memoryCache.alterTable(tableName, action, column);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.alterTable(tableName, action, column);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
});
|
||||
}
|
||||
// ---- CRUD ----
|
||||
async insert(tableName, rows) {
|
||||
const pks = await this.memoryCache.insert(tableName, rows);
|
||||
// 持久化到 OPFS
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return pks;
|
||||
return this.enqueueOp(async () => {
|
||||
const pks = await this.memoryCache.insert(tableName, rows);
|
||||
// 持久化到 OPFS(快照在队列内取,始终最新)
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return pks;
|
||||
});
|
||||
}
|
||||
async find(tableName, query) {
|
||||
return this.memoryCache.find(tableName, query);
|
||||
@@ -1746,50 +1788,62 @@
|
||||
return this.memoryCache.findStream(tableName, query, onRow);
|
||||
}
|
||||
async update(tableName, query, updates) {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
return this.enqueueOp(async () => {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
});
|
||||
}
|
||||
async delete(tableName, query) {
|
||||
const count = await this.memoryCache.delete(tableName, query);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
return this.enqueueOp(async () => {
|
||||
const count = await this.memoryCache.delete(tableName, query);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
});
|
||||
}
|
||||
async count(tableName, query) {
|
||||
return this.memoryCache.count(tableName, query);
|
||||
}
|
||||
async clear(tableName) {
|
||||
await this.memoryCache.clear(tableName);
|
||||
await this.writeTableData(tableName, []);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.clear(tableName);
|
||||
await this.writeTableData(tableName, []);
|
||||
});
|
||||
}
|
||||
// ---- 动态索引(v0.3.0) ----
|
||||
async createIndex(tableName, column, unique) {
|
||||
await this.memoryCache.createIndex(tableName, column, unique);
|
||||
// v0.4.2-fix: 索引标记持久化(重启后索引结构恢复)
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.createIndex(tableName, column, unique);
|
||||
// v0.4.2-fix: 索引标记持久化(重启后索引结构恢复)
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
});
|
||||
}
|
||||
async dropIndex(tableName, column, indexName) {
|
||||
await this.memoryCache.dropIndex(tableName, column, indexName);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.dropIndex(tableName, column, indexName);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema)
|
||||
await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
});
|
||||
}
|
||||
// ---- 事务 ----
|
||||
async beginTransaction() {
|
||||
await this.memoryCache.beginTransaction();
|
||||
}
|
||||
async commitTransaction() {
|
||||
await this.memoryCache.commitTransaction();
|
||||
// 将内存数据刷到 OPFS
|
||||
const tableNames = await this.memoryCache.getTableNames();
|
||||
for (const tableName of tableNames) {
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
}
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.commitTransaction();
|
||||
// 将内存数据刷到 OPFS
|
||||
const tableNames = await this.memoryCache.getTableNames();
|
||||
for (const tableName of tableNames) {
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
async rollbackTransaction() {
|
||||
await this.memoryCache.rollbackTransaction();
|
||||
@@ -1802,6 +1856,7 @@
|
||||
return this.tablesDir;
|
||||
}
|
||||
async writeTableData(tableName, data) {
|
||||
// 由 enqueueOp 串行化调用,此处直接写文件
|
||||
const dir = this.ensureDir();
|
||||
const fileName = `${tableName}.json`;
|
||||
const fileHandle = await dir.getFileHandle(fileName, { create: true });
|
||||
@@ -3276,6 +3331,11 @@
|
||||
this.compacting = false; // 防止重复触发 compaction
|
||||
/** 串行化 flush/compaction 链:保证持久化顺序与 id 分配顺序一致 */
|
||||
this.flushChain = Promise.resolve();
|
||||
/**
|
||||
* v0.4.3-fix: 最近一次后台 flush/compaction 失败。
|
||||
* 后台失败不卡死链(吞错防死锁),但在显式 flush()/close() 时报告(不静默)。
|
||||
*/
|
||||
this.lastBackgroundError = null;
|
||||
this.memtableSizeThreshold = config.memtableSizeThreshold ?? DEFAULT_MEMTABLE_SIZE;
|
||||
this.memtable = new MemTable(this.memtableSizeThreshold);
|
||||
this.levelSizeMultiplier = config.levelSizeMultiplier ?? DEFAULT_LEVEL_SIZE_MULTIPLIER;
|
||||
@@ -3373,11 +3433,26 @@
|
||||
return this.flushChain
|
||||
.then(task)
|
||||
.catch((error) => {
|
||||
// v0.4.3-fix: 记录失败(flush()/close() 时报告),不再完全静默吞错
|
||||
this.lastBackgroundError = error;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine LSM] background flush/compaction failed:', error);
|
||||
// catch 返回 undefined → 链恢复为 resolved,后续任务继续
|
||||
});
|
||||
}
|
||||
/**
|
||||
* v0.4.3-fix: 排空后台链 — 循环等待 flushChain 直到稳定。
|
||||
* 任务完成时可能级联调度新任务(compaction 多级触发),单次 await 等不到。
|
||||
* close/flush/clear 必须等待全部后台任务完成后才能安全关闭底层存储。
|
||||
*/
|
||||
async drainChain() {
|
||||
while (true) {
|
||||
const chain = this.flushChain;
|
||||
await chain;
|
||||
if (this.flushChain === chain)
|
||||
return;
|
||||
}
|
||||
}
|
||||
/** 将指定 Immutable MemTable 刷盘为 SSTable(id 由 store 按命名空间分配) */
|
||||
async flushImmutableAsync(frozen) {
|
||||
const entries = frozen.getAllEntries();
|
||||
@@ -3417,27 +3492,27 @@
|
||||
this.scheduleCompact(0);
|
||||
}
|
||||
}
|
||||
/** 异步调度 compaction,使用 setTimeout 分片执行 */
|
||||
/**
|
||||
* 异步调度 compaction。
|
||||
* v0.4.3-fix: 去掉 setTimeout 分片 — 此前未触发的定时器在 close 后执行,
|
||||
* 用已关闭的 backend 写存储(错误被吞),或 close 后 reopen 时旧闭包引用新 backend 交叉污染。
|
||||
* 现在直接挂在 flushChain 上:串行执行、close 的 drainChain 能等到全部完成。
|
||||
*/
|
||||
scheduleCompact(level) {
|
||||
if (level >= MAX_LSM_LEVELS - 1 || this.compacting)
|
||||
return;
|
||||
this.compacting = true;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.flushChain = this.enqueueOnChain(() => this.compactLevelAsync(level));
|
||||
this.flushChain = this.enqueueOnChain(() => this.compactLevelAsync(level).finally(() => {
|
||||
this.compacting = false;
|
||||
// 连续触发:如果 compaction 后仍然超标,继续调度
|
||||
if (this.levels[level].length >= 4) {
|
||||
this.scheduleCompact(level);
|
||||
}
|
||||
finally {
|
||||
this.compacting = false;
|
||||
// 连续触发:如果 compaction 后仍然超标,继续调度
|
||||
if (this.levels[level].length >= 4) {
|
||||
this.scheduleCompact(level);
|
||||
}
|
||||
// 检查下一级是否需要 compaction
|
||||
if (level + 1 < MAX_LSM_LEVELS - 1 && this.levels[level + 1].length >= 4) {
|
||||
this.scheduleCompact(level + 1);
|
||||
}
|
||||
// 检查下一级是否需要 compaction
|
||||
if (level + 1 < MAX_LSM_LEVELS - 1 && this.levels[level + 1].length >= 4) {
|
||||
this.scheduleCompact(level + 1);
|
||||
}
|
||||
}, 0);
|
||||
}));
|
||||
}
|
||||
/** 背压场景下排队 compaction(写入路径调用) */
|
||||
enqueueCompact(level) {
|
||||
@@ -3453,6 +3528,8 @@
|
||||
}
|
||||
}).catch((error) => {
|
||||
this.compacting = false;
|
||||
// v0.4.3-fix: 记录失败(flush()/close() 时报告)
|
||||
this.lastBackgroundError = error;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine LSM] background compaction failed:', error);
|
||||
});
|
||||
@@ -3463,10 +3540,11 @@
|
||||
/**
|
||||
* 预加载指定 key 范围内可能命中的所有 SSTable 到缓存。
|
||||
* 在同步扫描/查找之前调用,保证 loadSSTableReader 不会因缓存未命中而返回 null。
|
||||
* 先等待 flush 链完成:避免 flush 的缓存裁剪与预加载竞争(驱逐刚加载的 SSTable)。
|
||||
* v0.4.3-fix: 等待链稳定(drainChain)— 后台 flush/compaction 在链上动态增长,
|
||||
* 单次 await 后 compaction 仍可能合并 levels,导致扫描时新 meta 缓存未命中而跳块丢数据。
|
||||
*/
|
||||
async prefetchRange(startKey, endKey) {
|
||||
await this.flushChain;
|
||||
await this.drainChain();
|
||||
this.trimCache();
|
||||
const toLoad = [];
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -3485,7 +3563,8 @@
|
||||
async prefetchKeys(keys) {
|
||||
if (keys.length === 0)
|
||||
return;
|
||||
await this.flushChain;
|
||||
// v0.4.3-fix: 等待链稳定(同 prefetchRange,防 compaction 竞态丢数据)
|
||||
await this.drainChain();
|
||||
this.trimCache();
|
||||
const toLoad = new Set();
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -3676,8 +3755,14 @@
|
||||
}
|
||||
/** 等待所有排队的 flush/compaction 完成,并将剩余数据刷盘 */
|
||||
async flush() {
|
||||
// 等待链上已排队的 flush/compaction
|
||||
await this.flushChain;
|
||||
// v0.4.3-fix: 报告后台失败(消费一次,不永久吞错)
|
||||
if (this.lastBackgroundError !== null) {
|
||||
const error = this.lastBackgroundError;
|
||||
this.lastBackgroundError = null;
|
||||
throw new DatabaseError('AriaEngine background flush/compaction failed (data may be inconsistent)', 'ARIA_BACKGROUND_ERROR', error);
|
||||
}
|
||||
// v0.4.3-fix: 循环等待级联任务(flush 完成可能触发新的 compaction)
|
||||
await this.drainChain();
|
||||
// 若仍有 frozen 数据未刷盘,在链尾追加
|
||||
if (this.immutableMemtable) {
|
||||
const frozen = this.immutableMemtable;
|
||||
@@ -3690,8 +3775,12 @@
|
||||
await this.flushImmutableAsync(frozen);
|
||||
}
|
||||
this.frozenMemtables = [];
|
||||
// 刷盘完成后级联调度可能触发 compaction → 排空到稳定
|
||||
await this.drainChain();
|
||||
}
|
||||
async clear() {
|
||||
// v0.4.3-fix: 清空前排空后台任务(避免 compaction 在清空后写回残留 meta/数据)
|
||||
await this.drainChain();
|
||||
this.memtable.clear();
|
||||
this.immutableMemtable = null;
|
||||
this.frozenMemtables = [];
|
||||
@@ -6306,6 +6395,16 @@
|
||||
async commitTransaction() {
|
||||
if (!this.currentTxnId)
|
||||
throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
// v0.4.3-fix: 先持久化 WAL COMMIT,再合并快照到 LSM —
|
||||
// 崩溃在 WAL 提交后、快照合并前:恢复时 WAL 重放数据,重启一致;
|
||||
// 崩溃在 WAL 提交前:commitTransaction 尚未返回,事务视为未提交(可回滚)
|
||||
await this.wal.append({
|
||||
type: WALRecordType.COMMIT,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
await this.wal.flush();
|
||||
if (this.txnSnapshot) {
|
||||
for (const [key, value] of this.txnSnapshot) {
|
||||
if (value.__txn_deleted) {
|
||||
@@ -6317,15 +6416,8 @@
|
||||
}
|
||||
}
|
||||
this.mvcc.commitTransaction(this.currentTxnId);
|
||||
await this.wal.append({
|
||||
type: WALRecordType.COMMIT,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
this.currentTxnId = null;
|
||||
this.txnSnapshot = null;
|
||||
await this.wal.flush();
|
||||
}
|
||||
async rollbackTransaction() {
|
||||
if (!this.currentTxnId)
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@metona-team/metona-sqlark",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
|
||||
"type": "module",
|
||||
"main": "dist/metona-sqlark.cjs",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>⚡ 性能基准 — MetonaSqlark v0.4.2</title>
|
||||
<title>⚡ 性能基准 — MetonaSqlark v0.4.3</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🧪 在线演示 — MetonaSqlark v0.4.2</title>
|
||||
<title>🧪 在线演示 — MetonaSqlark v0.4.3</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
@@ -84,7 +84,7 @@
|
||||
<a href="demo.html" class="nav-active">演示</a>
|
||||
<a href="benchmark.html">基准</a>
|
||||
</nav>
|
||||
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.4.2</div>
|
||||
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.4.3</div>
|
||||
<button class="btn btn-preset" onclick="switchEngine('memory')" id="btn-memory" style="margin:6px 4px 6px 0;padding:6px 12px;">⚡ Memory</button>
|
||||
<button class="btn btn-preset" onclick="switchEngine('aria')" id="btn-aria" style="margin:6px 0;padding:6px 12px;color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
|
||||
</header>
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="main">
|
||||
<div class="editor-panel">
|
||||
<div class="editor-area">
|
||||
<textarea id="sql-input" placeholder="输入 SQL 语句... SELECT * FROM users; INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28); SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.4.2 在线演示
|
||||
<textarea id="sql-input" placeholder="输入 SQL 语句... SELECT * FROM users; INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28); SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.4.3 在线演示
|
||||
-- 已预置 users / orders / products 表数据
|
||||
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
|
||||
|
||||
@@ -554,7 +554,7 @@ SELECT COUNT(*) as total FROM temp_logs;
|
||||
|
||||
-- 清理
|
||||
DROP TABLE temp_logs;`,
|
||||
aria: `-- 🌲 AriaEngine 演示 (v0.4.2)
|
||||
aria: `-- 🌲 AriaEngine 演示 (v0.4.3)
|
||||
-- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine
|
||||
-- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter)
|
||||
-- 基础 CRUD 与 Memory 引擎完全兼容
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>📖 API 文档 — MetonaSqlark v0.4.2</title>
|
||||
<title>📖 API 文档 — MetonaSqlark v0.4.3</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
@@ -761,7 +761,8 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
|
||||
<p><strong>v0.2.0 新增</strong> — AriaEngine 是专为 MetonaSqlark 设计的页面式存储引擎,对标 SQLite 设计理念。<br>
|
||||
<strong>v0.2.4 生产级</strong> — 二级索引 · MVCC · BloomFilter · WAL CRC全同步 · AES-GCM加密 · Savepoint · EXPLAIN · ANALYZE · REINDEX · VACUUM · BufferPool · 零死代码。<br>
|
||||
<strong>v0.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 多标签页同步 · IDB schema持久化。<br>
|
||||
<strong>v0.4.2 生产就绪与崩溃自愈</strong> — 残缺 SSTable 打开自动跳过(不删库)· WAL 记录与计数原子写入 + 按 key 扫描恢复 · 事务进行中 checkpoint 不截断 WAL · 二级索引跨重启自动恢复 · ALTER TABLE / 事务内 DDL 全引擎持久化 · ON UPDATE 外键级联(含更新主键)· OPFS schema 持久化(空表/索引完整保留)· `repair()` / `clearAll()` 统一自愈接口 · 迁移版本持久化到库内 · 944 测试 50 套件。</p>
|
||||
<strong>v0.4.2 生产就绪与崩溃自愈</strong> — 残缺 SSTable 打开自动跳过(不删库)· WAL 记录与计数原子写入 + 按 key 扫描恢复 · 事务进行中 checkpoint 不截断 WAL · 二级索引跨重启自动恢复 · ALTER TABLE / 事务内 DDL 全引擎持久化 · ON UPDATE 外键级联(含更新主键)· OPFS schema 持久化(空表/索引完整保留)· `repair()` / `clearAll()` 统一自愈接口 · 迁移版本持久化到库内。<br>
|
||||
<strong>v0.4.3 关闭时序与后台任务加固</strong> — 后台 flush/compaction 不再使用 setTimeout 延迟(close 排空全部任务后才关闭存储,杜绝"backend 关闭后写存储/重开污染")· 后台失败在 `flush()`/`close()` 显式报告(`ARIA_BACKGROUND_ERROR`,不静默吞错)· 预加载等待链稳定(修复 compaction 竞态跳块丢数据)· 事务提交先落 WAL 再合并快照(崩溃一致)· OPFS 写操作串行队列 + close 等待 · 950 测试 51 套件。</p>
|
||||
|
||||
<h3>存储模式对比</h3>
|
||||
<table>
|
||||
|
||||
+4
-4
@@ -153,7 +153,7 @@
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.4.2 生产就绪与崩溃自愈 — 944测试 50套件 · 残缺SSTable自动跳过 · WAL原子写入 · ON UPDATE级联 · 二级索引跨重启恢复 · 零回归</div>
|
||||
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.4.3 关闭时序与后台任务加固 — 950测试 51套件 · close排空后台任务 · 失败不吞错 · compaction竞态修复 · 提交先WAL · 零回归</div>
|
||||
<h1>前端的 <span class="gradient-text">SQL 数据库</span></h1>
|
||||
<p>TypeScript 原生构建,5 种存储引擎,支持完整 SQL 查询。<br>零运行时依赖,开箱即用。AriaEngine 自研引擎:LSM-Tree + WAL 同步 + MVCC。</p>
|
||||
<div class="actions">
|
||||
@@ -243,7 +243,7 @@ npm install @metona-team/metona-sqlark
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🌲</div>
|
||||
<h3>AriaEngine <span style="font-size:0.65rem;color:var(--accent);vertical-align:super;">v0.4.2</span></h3>
|
||||
<h3>AriaEngine <span style="font-size:0.65rem;color:var(--accent);vertical-align:super;">v0.4.3</span></h3>
|
||||
<p>自研 LSM-Tree 页面式存储引擎。MemTable 红黑树 + 多级 SSTable、Bloom Filter 快速判存、WAL 原子写入 full模式真正同步、MVCC 快照隔离、崩溃恢复自动跳过残缺 SSTable、二级索引跨重启恢复、ON UPDATE/DELETE 外键级联。</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
@@ -404,12 +404,12 @@ npm install @metona-team/metona-sqlark
|
||||
<p>MetonaSqlark 的核心指标</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-card"><div class="num">944</div><div class="label">测试用例</div></div>
|
||||
<div class="stat-card"><div class="num">950</div><div class="label">测试用例</div></div>
|
||||
<div class="stat-card"><div class="num">84.2%</div><div class="label">行覆盖率</div></div>
|
||||
<div class="stat-card"><div class="num">~27KB</div><div class="label">gzip 体积</div></div>
|
||||
<div class="stat-card"><div class="num">5</div><div class="label">存储引擎</div></div>
|
||||
<div class="stat-card"><div class="num">36</div><div class="label">SQL 关键字</div></div>
|
||||
<div class="stat-card"><div class="num">50</div><div class="label">测试套件</div></div>
|
||||
<div class="stat-card"><div class="num">51</div><div class="label">测试套件</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+1
-1
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VERSION = '0.4.2';
|
||||
export const VERSION = '0.4.3';
|
||||
|
||||
@@ -1094,6 +1094,17 @@ export class AriaEngine implements IStorageEngine {
|
||||
async commitTransaction(): Promise<void> {
|
||||
if (!this.currentTxnId) throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
|
||||
// v0.4.3-fix: 先持久化 WAL COMMIT,再合并快照到 LSM —
|
||||
// 崩溃在 WAL 提交后、快照合并前:恢复时 WAL 重放数据,重启一致;
|
||||
// 崩溃在 WAL 提交前:commitTransaction 尚未返回,事务视为未提交(可回滚)
|
||||
await this.wal.append({
|
||||
type: WALRecordType.COMMIT,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
await this.wal.flush();
|
||||
|
||||
if (this.txnSnapshot) {
|
||||
for (const [key, value] of this.txnSnapshot) {
|
||||
if ((value as unknown as Record<string, unknown>).__txn_deleted) {
|
||||
@@ -1106,16 +1117,8 @@ export class AriaEngine implements IStorageEngine {
|
||||
|
||||
this.mvcc.commitTransaction(this.currentTxnId);
|
||||
|
||||
await this.wal.append({
|
||||
type: WALRecordType.COMMIT,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
|
||||
this.currentTxnId = null;
|
||||
this.txnSnapshot = null;
|
||||
await this.wal.flush();
|
||||
}
|
||||
|
||||
async rollbackTransaction(): Promise<void> {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MemTable } from './memtable';
|
||||
import { SSTableBuilder } from './sstable_builder';
|
||||
import { SSTableReader } from './sstable';
|
||||
import { MergeIterator, ArrayEntrySource } from './merge_iterator';
|
||||
import { DatabaseError } from '../../../constants';
|
||||
import type { SSTableMeta } from '../types';
|
||||
import {
|
||||
DEFAULT_MEMTABLE_SIZE,
|
||||
@@ -77,6 +78,11 @@ export class LSM {
|
||||
private compacting = false; // 防止重复触发 compaction
|
||||
/** 串行化 flush/compaction 链:保证持久化顺序与 id 分配顺序一致 */
|
||||
private flushChain: Promise<void> = Promise.resolve();
|
||||
/**
|
||||
* v0.4.3-fix: 最近一次后台 flush/compaction 失败。
|
||||
* 后台失败不卡死链(吞错防死锁),但在显式 flush()/close() 时报告(不静默)。
|
||||
*/
|
||||
private lastBackgroundError: unknown = null;
|
||||
|
||||
constructor(config: LSMConfig) {
|
||||
this.memtableSizeThreshold = config.memtableSizeThreshold ?? DEFAULT_MEMTABLE_SIZE;
|
||||
@@ -189,12 +195,27 @@ export class LSM {
|
||||
return this.flushChain
|
||||
.then(task)
|
||||
.catch((error) => {
|
||||
// v0.4.3-fix: 记录失败(flush()/close() 时报告),不再完全静默吞错
|
||||
this.lastBackgroundError = error;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine LSM] background flush/compaction failed:', error);
|
||||
// catch 返回 undefined → 链恢复为 resolved,后续任务继续
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.3-fix: 排空后台链 — 循环等待 flushChain 直到稳定。
|
||||
* 任务完成时可能级联调度新任务(compaction 多级触发),单次 await 等不到。
|
||||
* close/flush/clear 必须等待全部后台任务完成后才能安全关闭底层存储。
|
||||
*/
|
||||
private async drainChain(): Promise<void> {
|
||||
while (true) {
|
||||
const chain = this.flushChain;
|
||||
await chain;
|
||||
if (this.flushChain === chain) return;
|
||||
}
|
||||
}
|
||||
|
||||
/** 将指定 Immutable MemTable 刷盘为 SSTable(id 由 store 按命名空间分配) */
|
||||
private async flushImmutableAsync(frozen: MemTable): Promise<void> {
|
||||
const entries = frozen.getAllEntries();
|
||||
@@ -239,25 +260,26 @@ export class LSM {
|
||||
}
|
||||
}
|
||||
|
||||
/** 异步调度 compaction,使用 setTimeout 分片执行 */
|
||||
/**
|
||||
* 异步调度 compaction。
|
||||
* v0.4.3-fix: 去掉 setTimeout 分片 — 此前未触发的定时器在 close 后执行,
|
||||
* 用已关闭的 backend 写存储(错误被吞),或 close 后 reopen 时旧闭包引用新 backend 交叉污染。
|
||||
* 现在直接挂在 flushChain 上:串行执行、close 的 drainChain 能等到全部完成。
|
||||
*/
|
||||
private scheduleCompact(level: number): void {
|
||||
if (level >= MAX_LSM_LEVELS - 1 || this.compacting) return;
|
||||
this.compacting = true;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.flushChain = this.enqueueOnChain(() => this.compactLevelAsync(level));
|
||||
} finally {
|
||||
this.compacting = false;
|
||||
// 连续触发:如果 compaction 后仍然超标,继续调度
|
||||
if (this.levels[level].length >= 4) {
|
||||
this.scheduleCompact(level);
|
||||
}
|
||||
// 检查下一级是否需要 compaction
|
||||
if (level + 1 < MAX_LSM_LEVELS - 1 && this.levels[level + 1].length >= 4) {
|
||||
this.scheduleCompact(level + 1);
|
||||
}
|
||||
this.flushChain = this.enqueueOnChain(() => this.compactLevelAsync(level).finally(() => {
|
||||
this.compacting = false;
|
||||
// 连续触发:如果 compaction 后仍然超标,继续调度
|
||||
if (this.levels[level].length >= 4) {
|
||||
this.scheduleCompact(level);
|
||||
}
|
||||
}, 0);
|
||||
// 检查下一级是否需要 compaction
|
||||
if (level + 1 < MAX_LSM_LEVELS - 1 && this.levels[level + 1].length >= 4) {
|
||||
this.scheduleCompact(level + 1);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/** 背压场景下排队 compaction(写入路径调用) */
|
||||
@@ -272,6 +294,8 @@ export class LSM {
|
||||
}
|
||||
}).catch((error) => {
|
||||
this.compacting = false;
|
||||
// v0.4.3-fix: 记录失败(flush()/close() 时报告)
|
||||
this.lastBackgroundError = error;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine LSM] background compaction failed:', error);
|
||||
});
|
||||
@@ -284,10 +308,11 @@ export class LSM {
|
||||
/**
|
||||
* 预加载指定 key 范围内可能命中的所有 SSTable 到缓存。
|
||||
* 在同步扫描/查找之前调用,保证 loadSSTableReader 不会因缓存未命中而返回 null。
|
||||
* 先等待 flush 链完成:避免 flush 的缓存裁剪与预加载竞争(驱逐刚加载的 SSTable)。
|
||||
* v0.4.3-fix: 等待链稳定(drainChain)— 后台 flush/compaction 在链上动态增长,
|
||||
* 单次 await 后 compaction 仍可能合并 levels,导致扫描时新 meta 缓存未命中而跳块丢数据。
|
||||
*/
|
||||
async prefetchRange(startKey: string, endKey: string): Promise<void> {
|
||||
await this.flushChain;
|
||||
await this.drainChain();
|
||||
this.trimCache();
|
||||
const toLoad: number[] = [];
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -304,7 +329,8 @@ export class LSM {
|
||||
/** 预加载包含指定 key 的所有 SSTable 到缓存 */
|
||||
async prefetchKeys(keys: string[]): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
await this.flushChain;
|
||||
// v0.4.3-fix: 等待链稳定(同 prefetchRange,防 compaction 竞态丢数据)
|
||||
await this.drainChain();
|
||||
this.trimCache();
|
||||
const toLoad = new Set<number>();
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -519,8 +545,18 @@ export class LSM {
|
||||
|
||||
/** 等待所有排队的 flush/compaction 完成,并将剩余数据刷盘 */
|
||||
async flush(): Promise<void> {
|
||||
// 等待链上已排队的 flush/compaction
|
||||
await this.flushChain;
|
||||
// v0.4.3-fix: 报告后台失败(消费一次,不永久吞错)
|
||||
if (this.lastBackgroundError !== null) {
|
||||
const error = this.lastBackgroundError;
|
||||
this.lastBackgroundError = null;
|
||||
throw new DatabaseError(
|
||||
'AriaEngine background flush/compaction failed (data may be inconsistent)',
|
||||
'ARIA_BACKGROUND_ERROR',
|
||||
error,
|
||||
);
|
||||
}
|
||||
// v0.4.3-fix: 循环等待级联任务(flush 完成可能触发新的 compaction)
|
||||
await this.drainChain();
|
||||
// 若仍有 frozen 数据未刷盘,在链尾追加
|
||||
if (this.immutableMemtable) {
|
||||
const frozen = this.immutableMemtable;
|
||||
@@ -532,9 +568,13 @@ export class LSM {
|
||||
if (frozen) await this.flushImmutableAsync(frozen);
|
||||
}
|
||||
this.frozenMemtables = [];
|
||||
// 刷盘完成后级联调度可能触发 compaction → 排空到稳定
|
||||
await this.drainChain();
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
// v0.4.3-fix: 清空前排空后台任务(避免 compaction 在清空后写回残留 meta/数据)
|
||||
await this.drainChain();
|
||||
this.memtable.clear();
|
||||
this.immutableMemtable = null;
|
||||
this.frozenMemtables = [];
|
||||
|
||||
@@ -251,6 +251,12 @@ export class IndexedDBEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// v0.4.3-fix: 活跃事务时先回滚(避免 commit 对已关闭连接报错)
|
||||
if (this.txActive) {
|
||||
try {
|
||||
await this.rollbackTransaction();
|
||||
} catch { /* 回滚失败不阻塞关闭 */ }
|
||||
}
|
||||
if (this.db) {
|
||||
this.db.onversionchange = null; // 清理监听器
|
||||
this.db.close();
|
||||
|
||||
+95
-52
@@ -25,6 +25,20 @@ export class OPFSEngine implements IStorageEngine {
|
||||
// 运行时内存缓存(OPFS 文件读写有延迟)
|
||||
private memoryCache: MemoryEngine = new MemoryEngine();
|
||||
|
||||
/**
|
||||
* v0.4.3-fix: 写操作串行队列 — 内存写 + 快照 + 文件持久化整体排队执行,
|
||||
* close() 等待队列排空后再释放目录句柄(避免 close 后挂起写泄漏/读旧数据)。
|
||||
* 前一个操作失败不阻塞后续(错误仍返回给调用方)。
|
||||
*/
|
||||
private opQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** 将写操作加入串行队列(快照在队列内取,始终最新) */
|
||||
private enqueueOp<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = this.opQueue.then(fn, fn);
|
||||
this.opQueue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
async open(dbName: string, version: number): Promise<void> {
|
||||
@@ -42,6 +56,10 @@ export class OPFSEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// v0.4.3-fix: 等待所有挂起写操作完成(否则 close 后写仍在进行 → 重启读旧数据)
|
||||
try {
|
||||
await this.opQueue;
|
||||
} catch { /* 写失败已返回给调用方 */ }
|
||||
this.root = null;
|
||||
this.tablesDir = null;
|
||||
await this.memoryCache.close();
|
||||
@@ -55,6 +73,8 @@ export class OPFSEngine implements IStorageEngine {
|
||||
|
||||
/** 自愈:重置内存缓存后从 OPFS 重新加载(单文件损坏不影响其他表) */
|
||||
async repair(): Promise<void> {
|
||||
// v0.4.3-fix: 先等写队列排空(避免与挂起写竞态)
|
||||
try { await this.opQueue; } catch { /* ignore */ }
|
||||
await this.memoryCache.close();
|
||||
await this.memoryCache.open(this.dbName, 1);
|
||||
await this.loadExistingTables();
|
||||
@@ -62,6 +82,8 @@ export class OPFSEngine implements IStorageEngine {
|
||||
|
||||
/** 清空全部数据与表结构(删除目录内全部文件) */
|
||||
async clearAll(): Promise<void> {
|
||||
// v0.4.3-fix: 先等写队列排空
|
||||
try { await this.opQueue; } catch { /* ignore */ }
|
||||
await this.memoryCache.close();
|
||||
await this.memoryCache.open(this.dbName, 1);
|
||||
if (this.tablesDir) {
|
||||
@@ -94,28 +116,32 @@ export class OPFSEngine implements IStorageEngine {
|
||||
// ---- 表管理 ----
|
||||
|
||||
async createTable(schema: TableSchema): Promise<void> {
|
||||
await this.memoryCache.createTable(schema);
|
||||
// v0.4.2-fix: schema 持久化(此前仅写空数据文件 → 空表重启后消失、索引标记丢失)
|
||||
await this.setMeta(`schema_${schema.name}`, JSON.stringify(schema));
|
||||
// OPFS 中表以空 JSON 数组文件形式存在
|
||||
await this.writeTableData(schema.name, []);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.createTable(schema);
|
||||
// v0.4.2-fix: schema 持久化(此前仅写空数据文件 → 空表重启后消失、索引标记丢失)
|
||||
await this.setMeta(`schema_${schema.name}`, JSON.stringify(schema));
|
||||
// OPFS 中表以空 JSON 数组文件形式存在
|
||||
await this.writeTableData(schema.name, []);
|
||||
});
|
||||
}
|
||||
|
||||
async dropTable(tableName: string): Promise<void> {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
// v0.4.2-fix: 清理 schema meta(否则重启恢复幽灵表)
|
||||
if (this.tablesDir) {
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`__metona_schema_${tableName}.meta`);
|
||||
} catch {
|
||||
// 文件不存在则忽略
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
// v0.4.2-fix: 清理 schema meta(否则重启恢复幽灵表)
|
||||
if (this.tablesDir) {
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`__metona_schema_${tableName}.meta`);
|
||||
} catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`${tableName}.json`);
|
||||
} catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`${tableName}.json`);
|
||||
} catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async hasTable(tableName: string): Promise<boolean> {
|
||||
@@ -149,21 +175,25 @@ export class OPFSEngine implements IStorageEngine {
|
||||
action: 'ADD' | 'DROP',
|
||||
column: import('../constants').ColumnDef & { name: string },
|
||||
): Promise<void> {
|
||||
await this.memoryCache.alterTable(tableName, action, column);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.alterTable(tableName, action, column);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- CRUD ----
|
||||
|
||||
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
|
||||
const pks = await this.memoryCache.insert(tableName, rows);
|
||||
// 持久化到 OPFS
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return pks;
|
||||
return this.enqueueOp(async () => {
|
||||
const pks = await this.memoryCache.insert(tableName, rows);
|
||||
// 持久化到 OPFS(快照在队列内取,始终最新)
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return pks;
|
||||
});
|
||||
}
|
||||
|
||||
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
|
||||
@@ -176,17 +206,21 @@ export class OPFSEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
return this.enqueueOp(async () => {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
});
|
||||
}
|
||||
|
||||
async delete(tableName: string, query: QueryPlan): Promise<number> {
|
||||
const count = await this.memoryCache.delete(tableName, query);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
return this.enqueueOp(async () => {
|
||||
const count = await this.memoryCache.delete(tableName, query);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
});
|
||||
}
|
||||
|
||||
async count(tableName: string, query?: QueryPlan): Promise<number> {
|
||||
@@ -194,23 +228,29 @@ export class OPFSEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
async clear(tableName: string): Promise<void> {
|
||||
await this.memoryCache.clear(tableName);
|
||||
await this.writeTableData(tableName, []);
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.clear(tableName);
|
||||
await this.writeTableData(tableName, []);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 动态索引(v0.3.0) ----
|
||||
|
||||
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
||||
await this.memoryCache.createIndex(tableName, column, unique);
|
||||
// v0.4.2-fix: 索引标记持久化(重启后索引结构恢复)
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.createIndex(tableName, column, unique);
|
||||
// v0.4.2-fix: 索引标记持久化(重启后索引结构恢复)
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
});
|
||||
}
|
||||
|
||||
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
|
||||
await this.memoryCache.dropIndex(tableName, column, indexName);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.dropIndex(tableName, column, indexName);
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 事务 ----
|
||||
@@ -220,13 +260,15 @@ export class OPFSEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
async commitTransaction(): Promise<void> {
|
||||
await this.memoryCache.commitTransaction();
|
||||
// 将内存数据刷到 OPFS
|
||||
const tableNames = await this.memoryCache.getTableNames();
|
||||
for (const tableName of tableNames) {
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
}
|
||||
return this.enqueueOp(async () => {
|
||||
await this.memoryCache.commitTransaction();
|
||||
// 将内存数据刷到 OPFS
|
||||
const tableNames = await this.memoryCache.getTableNames();
|
||||
for (const tableName of tableNames) {
|
||||
const rows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async rollbackTransaction(): Promise<void> {
|
||||
@@ -243,6 +285,7 @@ export class OPFSEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
private async writeTableData(tableName: string, data: Record<string, unknown>[]): Promise<void> {
|
||||
// 由 enqueueOp 串行化调用,此处直接写文件
|
||||
const dir = this.ensureDir();
|
||||
const fileName = `${tableName}.json`;
|
||||
const fileHandle = await dir.getFileHandle(fileName, { create: true });
|
||||
|
||||
@@ -23,8 +23,8 @@ import { createSchema } from '../src/table/schema';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
||||
test('VERSION 常量为当前版本(0.4.2)', () => {
|
||||
expect(VERSION).toBe('0.4.2');
|
||||
test('VERSION 常量为当前版本(0.4.3)', () => {
|
||||
expect(VERSION).toBe('0.4.3');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -400,7 +400,7 @@ describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
|
||||
|
||||
describe('[v0.3.3] 端到端', () => {
|
||||
test('全部修复点可共存于 MetonaSqlark API', async () => {
|
||||
expect(VERSION).toBe('0.4.2');
|
||||
expect(VERSION).toBe('0.4.3');
|
||||
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* v0.4.3 回归测试
|
||||
* 覆盖:
|
||||
* - P0: close 后后台 compaction 在 backend 关闭后执行(残留任务污染/吞错)
|
||||
* - P0: flush 报告后台失败(不再静默吞错)
|
||||
* - P1: commit 先持久化 WAL 再合并快照(WAL 失败时数据一致性)
|
||||
* - P1: OPFS close 等待挂起写完成
|
||||
*/
|
||||
|
||||
import { AriaEngine } from '../src/engine/aria/index';
|
||||
import { createSchema } from '../src/table/schema';
|
||||
import { LSM } from '../src/engine/aria/index/lsm';
|
||||
import { OPFSEngine } from '../src/engine/opfs';
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
let idbCounter = 0;
|
||||
function uniqueDB(): string {
|
||||
return `r43-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// P0: close 与后台 compaction
|
||||
// ===================================================================
|
||||
|
||||
describe('P0 — close 后无残留后台任务', () => {
|
||||
it('close 等待后台 compaction 完成(backend 关闭后无残留写)', async () => {
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'memory',
|
||||
// 小缓存 + 小 memtable:flush 文件超缓存上限被驱逐 → compaction 缓存未命中 → 触发调度
|
||||
bufferPoolPages: 4,
|
||||
memtableSizeThreshold: 32 * 1024,
|
||||
checkpointInterval: 100000,
|
||||
});
|
||||
await engine.open(uniqueDB(), 1);
|
||||
await engine.createTable(createSchema('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
v: { type: 'number' },
|
||||
data: { type: 'string' },
|
||||
}));
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await engine.insert('t', [{ id: `k${String(i).padStart(4, '0')}`, v: i, data: 'x'.repeat(200) }]);
|
||||
}
|
||||
await (engine as any).lsm.flush();
|
||||
const backend = (engine as any).backend;
|
||||
|
||||
// 立即 close:修复前 setTimeout compaction 在 backend.close() 后才执行 → 残留写
|
||||
await engine.close();
|
||||
|
||||
// 给残留 setTimeout 执行机会
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const keysAfterClose = await (backend as any).listKeys();
|
||||
// 修复前:close 后 compaction 写入 → store 残留 sst_* 文件
|
||||
expect(keysAfterClose).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('close 后立即 reopen 不被旧后台任务污染', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'indexeddb',
|
||||
bufferPoolPages: 4,
|
||||
memtableSizeThreshold: 32 * 1024,
|
||||
checkpointInterval: 100000,
|
||||
});
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(createSchema('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
v: { type: 'number' },
|
||||
data: { type: 'string' },
|
||||
}));
|
||||
for (let i = 0; i < 300; i++) {
|
||||
await engine.insert('t', [{ id: `k${String(i).padStart(4, '0')}`, v: i, data: 'x'.repeat(200) }]);
|
||||
}
|
||||
await (engine as any).lsm.flush();
|
||||
// 立即 close + 立即 reopen(修复前:旧任务的闭包引用新 backend → 交叉写)
|
||||
await engine.close();
|
||||
await engine.open(dbName, 1);
|
||||
// 新库数据必须完整(不被旧任务破坏)
|
||||
expect(await engine.count('t')).toBe(300);
|
||||
await engine.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// P0: 后台失败可见性(不吞错)
|
||||
// ===================================================================
|
||||
|
||||
describe('P0 — flush 报告后台失败', () => {
|
||||
class FailingStore {
|
||||
failNext = true;
|
||||
saved = 0;
|
||||
deleted = 0;
|
||||
metas: { id: number; level: number }[] = [];
|
||||
async save(id: number, _data: Uint8Array): Promise<void> {
|
||||
if (this.failNext) {
|
||||
this.failNext = false;
|
||||
throw new Error('disk full (simulated)');
|
||||
}
|
||||
this.saved++;
|
||||
}
|
||||
async load(_id: number): Promise<Uint8Array | null> { return null; }
|
||||
async delete(_id: number): Promise<void> { this.deleted++; }
|
||||
async allocateId(): Promise<number> { return ++this.saved; }
|
||||
async listMeta(): Promise<{ id: number; level: number }[]> { return this.metas; }
|
||||
async saveMeta(meta: { id: number; level: number }): Promise<void> { this.metas.push(meta); }
|
||||
async deleteMeta(id: number): Promise<void> { this.metas = this.metas.filter((m) => m.id !== id); }
|
||||
}
|
||||
|
||||
it('后台 flush 失败后 flush() 抛 DatabaseError(ARIA_BACKGROUND_ERROR)', async () => {
|
||||
const store = new FailingStore();
|
||||
const lsm = new LSM({
|
||||
memtableSizeThreshold: 64,
|
||||
sstableStore: store as any,
|
||||
});
|
||||
// 触发 freeze + 后台 flush(save 抛错 → 记录 lastBackgroundError)
|
||||
for (let i = 0; i < 200; i++) {
|
||||
lsm.put(`k${i}`, { v: i });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// flush 必须报告后台失败(修复前静默吞错)
|
||||
await expect(lsm.flush()).rejects.toMatchObject({ code: 'ARIA_BACKGROUND_ERROR' });
|
||||
// 再次 flush:错误已消费,正常完成
|
||||
await expect(lsm.flush()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('后台 compaction 失败后 flush() 报告(链不卡死)', async () => {
|
||||
const store = new FailingStore();
|
||||
const lsm = new LSM({
|
||||
memtableSizeThreshold: 32,
|
||||
sstableStore: store as any,
|
||||
});
|
||||
for (let i = 0; i < 500; i++) {
|
||||
lsm.put(`k${i}`, { v: i });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
// 链不卡死:flush 要么成功要么报告错误(不能永久 pending)
|
||||
const result = await Promise.race([
|
||||
lsm.flush().then(() => 'ok', (e) => `err:${(e as any).code}`),
|
||||
new Promise((r) => setTimeout(() => r('pending'), 500)),
|
||||
]);
|
||||
expect(result).not.toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// P1: commit 顺序与 OPFS close
|
||||
// ===================================================================
|
||||
|
||||
describe('P1 — 提交顺序与 close 等待', () => {
|
||||
it('commit 先持久化 WAL 再合并快照(WAL 始终领先)', async () => {
|
||||
// 通过顺序断言:事务 INSERT 的 WAL 记录必须在快照合并可见之前已落盘
|
||||
const engine = new AriaEngine({ storageBackend: 'memory' });
|
||||
await engine.open(uniqueDB(), 1);
|
||||
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
|
||||
await engine.beginTransaction();
|
||||
await engine.insert('t', [{ id: '1' }]);
|
||||
// 快照合并前:WAL 已含事务记录(batch 模式 flush 时机校验)
|
||||
await engine.commitTransaction();
|
||||
// 崩溃恢复路径:WAL 完整则恢复数据
|
||||
const backend = (engine as any).backend;
|
||||
const walKeys = (await backend.listKeys()).filter((k) => k.startsWith('__wal_'));
|
||||
expect(walKeys.length).toBeGreaterThan(0);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('OPFS close 等待挂起写完成(文件完整)', async () => {
|
||||
// mock OPFS(跨实例共享)
|
||||
const files = new Map<string, string>();
|
||||
const dirMock = {
|
||||
getDirectoryHandle: async (_n: string, _o?: any) => dirMock as any,
|
||||
getFileHandle: async (name: string, opts?: any) => {
|
||||
if (opts?.create) {
|
||||
return {
|
||||
createWritable: async () => ({
|
||||
write: async (d: string) => {
|
||||
// 模拟慢 I/O
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
files.set(name, d);
|
||||
},
|
||||
close: async () => {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!files.has(name)) throw new Error('Not found');
|
||||
return { getFile: async () => ({ text: async () => files.get(name)!, arrayBuffer: async () => new ArrayBuffer(0) }) };
|
||||
},
|
||||
removeEntry: async (n: string) => { files.delete(n); },
|
||||
};
|
||||
(dirMock as any).entries = () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
for (const [k] of files) yield [k];
|
||||
},
|
||||
});
|
||||
const nav = (globalThis as any).navigator || {};
|
||||
nav.storage = { getDirectory: async () => dirMock };
|
||||
(globalThis as any).navigator = nav;
|
||||
|
||||
const engine = new OPFSEngine();
|
||||
await engine.open('opfs-close', 1);
|
||||
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
|
||||
// 写入不等待(写 I/O 30ms 挂起)
|
||||
const p = engine.insert('t', [{ id: '1' }]);
|
||||
// 立即 close:必须等待挂起写完成
|
||||
await engine.close();
|
||||
await p;
|
||||
// 文件完整(修复前 close 不等写 → 可能读到旧/空文件)
|
||||
expect(files.get('t.json')).toContain('"1"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user