Files
MetonaSqlark/src/engine/aria/index.ts
T
thzxx 0b44620721 fix(v0.8.0): 全量回归审查 —— 1 处 P0 数据丢失 + 4 处 P1 + 9 处 P2 根因修复
方法:四个对抗性子代理分头审查(数据正确性 / 文档宣称 vs 实现 / 公共 API 契约 /
测试质量),每条结论要求可复现证据;逐条复核 + 探针确认 + 变异验证(40 项全部
被对应用例拦住)。

P0:事务活跃期间 repair()/close()/周期 checkpoint 推进 WAL 水位 → 已 COMMIT 的
事务整批消失且恢复报告"干净"。根因 hasPendingFlushData()/computeDurableLsn()
不看 txnSnapshot;守卫此前只在 CheckpointManager 两个回调里。修复:守卫下沉到
computeDurableLsn() 与 advanceWalCheckpoint() 入口(唯一实现)。

P1:
- WAL 前缀缺失丢弃整段活分片(回退上一代 manifest 时 kept 为空)→ 前缀缺失单独
  记录,后缀照常重放;仅 fromLsn === 0 时才算真异常
- 孤儿回收门槛只看引擎层 dataLossSuspected,漏掉 LSM 层被丢的 SSTable →
  统一 describeRecoveryDamage() 聚合判定(损坏时绝不删"引用不到"的文件)
- vacuum() 逐层压缩绕过维护链 → vacuumLevels() 每层作为维护链任务执行
- reclaimRetiredNow() 无视在途读者(读者把"已退休"读成"文件损坏")→ 有读者时
  退化为延迟回收

P2:WAL 记录级 CRC 损坏不计数不上报;旧格式表结构记录形状损坏静默当空库;
bloomFilterBitsPerKey 配置被接受却完全不生效(构建器写死默认值,实现缺陷);
幽灵 meta;介质读故障等于文件损坏的语义无用例;manifest 回读校验两条守卫无用例;
文件名≠载荷世代判定无用例;pageIdWatermark 单调性无用例;分片号两条真实不变量
无用例。

覆盖率口径(第二处漏洞):interface.ts 混着三个运行时函数(cloneRow 等)却被
描述为"纯类型、不纳入统计" → 实现搬到 src/engine/row_clone.ts;搬完门禁真的
失败(functions 93.84% < 94%),补测退化路径后通过。

测试质量:3 条空壳用例改值级断言;1 条"全损坏"用例实际只走缓存 → 拆成两条真
用例;5 秒墙钟 race 改门控 + 失败上限;setTimeout 改 whenIdle();<= 收紧为 <。

变异脚本加固:正控(干净基线必须全绿)、编译失败/0 用例单独归类、300s 超时、
逐字节 sha256 恢复校验、O_EXCL 进程锁、锚点唯一性;变异 22 → 40 项。

文档两轮订正(16 + 11 条不成立宣称):MVCC 快照隔离、backup 一致性快照、
"空洞检测截断"、体积(251,109 B / gzip 63,145 B)、测试与覆盖率数字、
"5 种存储引擎"、Tree-shakable、错误码表补 16 个码、恢复报告字段、已知限制
(回退单向 / 多实例依赖 Web Locks / manifest 体积 / 尾部 WAL 分片不可识别)。

验证:常规套件 92 套件 / 1980 用例全绿;覆盖率 90.59 / 82.59 / 94.14 / 93.50
(阈值 90/82/94/93);e2e 14/14(真实 Chromium + OPFS + CDP 崩溃);
重型套件 4 套件 / 27 用例;变异 40/40;lint + 两份 tsc 干净;dist 已重建。
2026-09-15 16:33:40 +08:00

3160 lines
138 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cloneRow } from '../row_clone';
/**
* AriaEngine — 自研页面式存储引擎主类
* @module engine/aria/index
*
* v0.4.1: 外键级联 + ALTER TABLE 重写 + clearAll 重置 + 崩溃恢复加固
*/
import type { IStorageEngine } from '../interface';
import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants';
import { DatabaseError } from '../../constants';
import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher';
import { stripUndefinedUpdates } from '../../table/schema';
import { compileValidator } from '../../table/validation';
import type { AriaEngineConfig, SSTableMeta } from './types';
import { DEFAULT_ARIA_CONFIG } from './types';
import { LSM } from './index/lsm';
import type { SSTableStore } from './index/lsm';
import { WAL } from './wal/log';
import { SegmentedWALStore } from './wal/segmented_store';
import { DatabaseLock } from './locks';
import { WALRecordType, type WALRecord } from './types';
import { CheckpointManager } from './wal/checkpoint';
import { MemoryBackend, type IStorageBackend } from './store/backend';
import { KVStoreBackend } from './store/kvstore_backend';
import { OPFSBackend } from './store/opfs_backend';
import { EncryptedBackend } from './store/encrypted_backend';
import { PageSSTableStore } from './store/page_sstable_store';
import { FileManager } from './store/file_manager';
import {
ManifestStore,
createEmptyManifest,
type AriaManifest,
type ManifestFrozenIntent,
} from './store/manifest';
import { MVCCManager } from './transaction/mvcc';
import { BufferPool } from './buffer/pool';
import { compressLZ4, decompressLZ4 } from './compression/lz4';
import type { WALRecord as _WALRecord } from './types';
/** v0.8.0:引擎恢复诊断(供上层展示/断言;不静默) */
export interface AriaRecoveryReport {
/** 打开过程中被丢弃的 SSTable(按命名空间) */
droppedSSTables: { namespace: string; id: number; level: number; reason: string }[];
/** 是否怀疑已确认写入丢失(被丢弃的 SSTable 没有 WAL 兜底) */
dataLossSuspected: boolean;
/** WAL 活跃区间内的分片空洞(已提交事务记录缺失) */
walGaps: number[];
/** v0.8.0CRC 校验失败被跳过的 WAL 记录数(它们承载的写入已丢失) */
droppedWALRecords: number;
/** 本次打开是否从旧格式(__aria_lsm_meta/__aria_schemas)迁移而来 */
legacyImported: boolean;
/** 是否从更早的 manifest 世代回退(最新世代损坏) */
manifestFallback: boolean;
}
// ---------------------------------------------------------------------------
// AriaEngine
// ---------------------------------------------------------------------------
export class AriaEngine implements IStorageEngine {
readonly name = 'aria';
/**
* 生效配置。`testBackend` 与 `pageStorage`/`encryption` 一样是**可选**的
* (不参与 Required),否则 DEFAULT_ARIA_CONFIG 会被迫提供一个假后端。
*/
private config!: Required<Omit<AriaEngineConfig, 'encryption' | 'pageStorage' | 'testBackend'>> &
Pick<AriaEngineConfig, 'encryption' | 'pageStorage' | 'testBackend'>;
private lsm!: LSM; // 主键索引 LSM
private wal!: WAL;
private checkpointManager!: CheckpointManager;
private backend!: IStorageBackend;
private opened = false;
private dbName = '';
// v0.4.5: 页面化物理存储(FileManager + BufferPool 提升为引擎字段,close/repair 时落盘/清理)
private fileManager!: FileManager;
private bufferPool!: BufferPool;
// v0.4.5: 多标签页独占锁(Web Locks API,OPFS 等无事务后端防并发写)
private dbLock: DatabaseLock | null = null;
/**
* v0.8.0(B-6):存储层单一提交点。
*
* 页面水位 / 各命名空间 SSTable 列表 / 表结构 / WAL 起始位置 / 待落盘冻结表意图
* 全部收敛到 `__aria_manifest_<generation>`:数据先落盘,再提交 manifest,
* **提交成功后**才允许截断 WAL 或删除旧分片。恢复只认最后一份 CRC 通过的世代。
*/
private manifestStore!: ManifestStore;
private manifest!: AriaManifest;
/**
* v0.8.0:已确认落盘的 WAL 水位(LSN)。
*
* 只在"所有 LSM 都没有未落盘数据"时推进到当前 LSN —— 于是
* `lsn <= durableLsn` 的记录必然已存在于已提交的 SSTable 中,
* 恢复时可以安全跳过(也就允许删除对应分片)。
*/
private durableLsn = 0;
/**
* v0.8.0:仍需保留的最小 WAL 分片号(每次 `checkpointBefore` 的返回值)。
* 写进 manifest 的 `wal.startSegment`:即便分片删除只完成一半,恢复也只从
* 这个分片开始读,不会把上一世代的旧记录排到新记录之后重放。
*/
private walStartSegment = 0;
/** v0.8.0:恢复诊断 */
private recoveryReport: AriaRecoveryReport = {
droppedSSTables: [],
dataLossSuspected: false,
walGaps: [],
droppedWALRecords: 0,
legacyImported: false,
manifestFallback: false,
};
// 表结构
private schemas: Map<string, TableSchema> = new Map();
private tablePKs: Map<string, string> = new Map();
private opCounter = 0;
// 二级索引:table.colKey → LSM
private secondaryIndexes: Map<string, LSM> = new Map();
/**
* v0.7.4: 由 CREATE UNIQUE INDEX 添加的 unique 列(table:col)。
* 与建表 UNIQUE 约束区分:DROP INDEX 只允许解除索引来源的 unique,
* 建表约束需重建表(对齐 SQLite 语义,此前静默解除且重启后永久消失)。
* 注:重启后无法区分历史来源,schema 中的 unique 一律按建表约束保护(保守)。
*/
private uniqueIndexCols: Set<string> = new Set();
// MVCC 事务
private mvcc: MVCCManager = new MVCCManager();
private currentTxnId: number | null = null;
private txnSnapshot: Map<string, Record<string, unknown>> | null = null;
private gcCounter = 0;
constructor(config: AriaEngineConfig = {}) {
this.config = { ...DEFAULT_ARIA_CONFIG, ...config };
}
// =======================================================================
// 生命周期
// =======================================================================
async open(dbName: string, _version: number): Promise<void> {
if (this.opened) return;
// v0.4.2-fix: 引擎内部错误统一包装为 DatabaseErrorARIA_OPEN_ERROR),
// 应用层可拿到 code 分类处理,不再抛出原生 RangeError/TypeError
try {
await this.openInternal(dbName);
} catch (error) {
// 打开失败:释放已获取的锁(避免锁泄漏阻塞其他标签页)
if (this.dbLock) {
try { await this.dbLock.release(); } catch { /* ignore */ }
this.dbLock = null;
}
if (error instanceof DatabaseError) throw error;
throw new DatabaseError(
`Failed to open AriaEngine database "${dbName}"`,
'ARIA_OPEN_ERROR',
error,
);
}
}
/** open 内部实现(错误包装在 open 外层) */
private async openInternal(dbName: string): Promise<void> {
this.dbName = dbName;
// v0.4.5: 多标签页独占锁(Web Locks)— 不支持的环境降级为无锁(文档注明)
const lock = new DatabaseLock();
this.dbLock = lock;
const lockAcquired = await lock.acquire(dbName);
if (!lockAcquired) {
// eslint-disable-next-line no-console
console.warn(
`[AriaEngine] Web Locks API unavailable: no multi-tab protection for "${dbName}" ` +
'(open the same database in multiple tabs may corrupt data)',
);
}
// 1. 存储后端(可选全库加密包装)
let baseBackend: IStorageBackend;
// v0.8.0:测试可注入后端(见 AriaEngineConfig.testBackend 的说明)——
// 崩溃语义必须让 WAL/FileManager/SSTableStore 都走同一个被测后端。
if (this.config.testBackend) {
baseBackend = this.config.testBackend;
} else if (this.config.storageBackend === 'opfs') {
baseBackend = new OPFSBackend();
} else if (this.config.storageBackend === 'kv') {
// v0.6.1: 自研 KVStore 后端(aria 完全跑在自研存储栈上,不依赖浏览器 OPFS)
baseBackend = new KVStoreBackend();
} else {
baseBackend = new MemoryBackend();
}
await baseBackend.open(dbName);
// v0.4.5: encryption 配置 → 透明加密封装(密码错误/数据损坏在 open 或首次读取时暴露)
if (this.config.encryption?.password) {
this.backend = new EncryptedBackend(baseBackend, this.config.encryption.password);
} else {
this.backend = baseBackend;
// 反向检测:库中存在密钥元数据但未提供密码 → 拒绝打开(避免密文被当明文解析成空库)
if (await baseBackend.exists('__aria_keymeta')) {
await baseBackend.close();
throw new DatabaseError(
'Database is encrypted: provide encryption.password to open it',
'ARIA_ENCRYPT_REQUIRED',
);
}
}
await this.backend.open(dbName);
// ---------------------------------------------------------------------
// 2. manifest(单一提交点):加载 → 旧格式迁移 → 认领所有权
// ---------------------------------------------------------------------
this.manifestStore = new ManifestStore({ backend: this.backend });
const loaded = await this.manifestStore.load();
if (loaded.hadInvalidGenerations) {
// 有损坏世代被跳过:记录 + 告警(但仍可用更早的有效世代打开)
this.recoveryReport.manifestFallback = true;
// eslint-disable-next-line no-console
console.warn(
`[AriaEngine] manifest: skipped invalid generation(s): ` +
loaded.skipped.map((s) => `${s.generation} (${s.reason})`).join('; '),
);
}
if (loaded.manifest === null) {
// 全新库或 v0.8.0 之前的旧格式库:先把旧格式状态**完整导入**,
// 再进行第一次提交 —— 顺序反了会写出"空 manifest"覆盖旧状态(静默空库)。
this.manifest = createEmptyManifest({ instanceId: this.manifestStore.instanceId });
this.manifestStore.adopt(this.manifest);
await this.importLegacyState();
} else {
this.manifest = loaded.manifest;
}
// 认领所有权(世代 +1;允许接手别的实例 —— 之后旧实例的提交会被拒绝)
this.manifest = await this.manifestStore.claimOwnership();
this.durableLsn = this.manifest.wal.startLsn;
this.walStartSegment = this.manifest.wal.startSegment;
// ---------------------------------------------------------------------
// 3. WALLSN 从 manifest 高水位续接,全库单调)
// ---------------------------------------------------------------------
// v0.4.5: 分片式 WAL 存储(__wal_%06d.bin),序号内嵌记录字节流无需 count 键,
// append 单文件原子写;空洞检测截断;兼容旧格式 __wal_N + __wal_count
this.wal = new WAL(
new SegmentedWALStore(this.backend),
this.config.walEnabled,
this.config.walSyncMode,
);
this.wal.setLsn(this.manifest.wal.nextLsn);
// ---------------------------------------------------------------------
// 4. FileManager (PageIO 实现) + Buffer Pool(页面水位下限来自 manifest
// ---------------------------------------------------------------------
this.fileManager = new FileManager(this.backend);
await this.fileManager.init(dbName, this.manifest.pageIdWatermark);
this.bufferPool = new BufferPool(this.fileManager, this.config.bufferPoolPages);
// ---------------------------------------------------------------------
// 5. 主 LSMPK 索引)+ 二级索引 LSM
// ---------------------------------------------------------------------
this.lsm = this.createLSM('main');
// 5a. 恢复 Schemamanifest 权威;旧格式已在迁移阶段导入)
await this.loadSchemas();
// v0.4.2-fix: 为 schema 中带 index/unique 标记的列重建二级索引 LSM。
// 此前重开只恢复 schema 不恢复索引 LSM → 索引查询静默回退全表、
// createIndex 因 colDef 已有标记直接 return → 索引永久缺失。
// 索引数据已持久化在独立命名空间(manifest.namespaces),init() 直接加载。
for (const [tableName, schema] of this.schemas) {
const pkCol = this.tablePKs.get(tableName)!;
for (const [colName, colDef] of Object.entries(schema.columns)) {
if ((colDef.index || colDef.unique) && colName !== pkCol) {
const idxKey = `${tableName}:idx:${colName}`;
if (!this.secondaryIndexes.has(idxKey)) {
const idxLsm = this.createLSM(`idx_${tableName}_${colName}`);
await idxLsm.init();
this.secondaryIndexes.set(idxKey, idxLsm);
}
}
}
}
await this.lsm.init();
// ---------------------------------------------------------------------
// 6. WAL 恢复(两阶段:先扫描事务边界,仅回放已提交事务)
// ---------------------------------------------------------------------
const committedTxns = new Set<number>();
const allRecords: WALRecord[] = [];
await this.wal.recover((r) => allRecords.push(r), {
// manifest 权威位置:小于 startLsn 的记录已确认落盘 → 跳过
//(否则旧记录会把已删除的行复活);分片读取从 startSegment 起。
fromSegment: this.manifest.wal.startSegment,
fromLsn: this.manifest.wal.startLsn,
// 空洞由引擎显式上报(恢复报告 + 告警),而不是静默丢弃尾部
allowGaps: true,
});
const walInfo = this.wal.getLastRecoveryInfo();
if (walInfo && walInfo.corruptRecords > 0) {
// v0.8.0review 修复):记录级 CRC 损坏此前只 console.warn —— 恢复完全
// 不感知"少了几条记录",与"静默丢数据必须显式化"的目标不符。
this.recoveryReport.droppedWALRecords = walInfo.corruptRecords;
this.recoveryReport.dataLossSuspected = true;
// eslint-disable-next-line no-console
console.warn(
`[AriaEngine] WAL: ${walInfo.corruptRecords} record(s) failed CRC and were skipped — ` +
'the writes they carried are missing (recovery report records this)',
);
}
if (walInfo && walInfo.gaps.length > 0) {
this.recoveryReport.walGaps = [...walInfo.gaps];
this.recoveryReport.dataLossSuspected = true;
// eslint-disable-next-line no-console
console.warn(
`[AriaEngine] WAL segment gap detected: missing segment(s) ${walInfo.gaps.join(', ')} — ` +
'transactions in the missing range are lost (recovery report records this)',
);
}
// 第一遍:确定已提交事务
for (const r of allRecords) {
if (r.type === WALRecordType.COMMIT) committedTxns.add(r.txnId);
if (r.type === WALRecordType.ROLLBACK) committedTxns.delete(r.txnId);
}
// v0.8.0 根治:确定每个事务的**回放起始下标**。
//
// `ROLLBACK TO <savepoint>` 只回滚内存快照,日志里仍留有 savepoint 之前
// 写入的记录;COMMIT 又把整个 txnId 标记为已提交 —— 于是那些被回滚掉的写入
// 在重启时被重新应用,**已回滚的行复活**(实测:实时只剩 a,崩溃重开变成 a+b)。
//
// 现在 savepoint 回滚会写入 SAVEPOINT_ROLLBACK 记录;恢复时该事务只应用
// **最后一条** SAVEPOINT_ROLLBACK 之后的记录 —— 等价于"回到该保存点"
// 与实时态严格一致(这样也就不需要 undo 崩溃前已落盘的旧值)。
// 每个事务:{ 保留起点, 丢弃终点 } —— 区间 [start, end) 保留。
//
// 关键:`replayFromIndex` 是**事务内**的记录序号(写入方按事务计数),
// 因此这里必须用"该事务的第几条记录"来比较,不能直接拿全局下标 ——
// 全局下标里还混着 txnId=0 的非事务记录(CREATE_TABLE 等)以及其它事务。
const globalIndexToTxnIndex = new Map<number, number>();
const txnRecordCount = new Map<number, number>();
for (let i = 0; i < allRecords.length; i++) {
const r = allRecords[i];
if (r.txnId === 0) continue;
const n = txnRecordCount.get(r.txnId) ?? 0;
globalIndexToTxnIndex.set(i, n);
txnRecordCount.set(r.txnId, n + 1);
}
// 边界语义(必须显式定义,否则差一错误就在这里):
// 写入侧记录的 `replayFromIndex = N` 表示"保存点建立时,本事务已成功追加了 N 条记录",
// 即事务的第 0..N-1 条记录必须保留(BEGIN 是第 0 条)。
// 因此恢复侧应保留的**事务内下标区间**是 [0, N),丢弃 [N, 标记位置)。
// 等价地:只丢弃"事务内下标 >= N"且"在最后一个标记之前"的记录。
const txnReplayWindow = new Map<number, { keepUpTo: number; dropFrom: number }>();
for (let i = 0; i < allRecords.length; i++) {
const r = allRecords[i];
if (r.type !== WALRecordType.SAVEPOINT_ROLLBACK) continue;
const declared = (r.data as { replayFromIndex?: number } | undefined)?.replayFromIndex;
const keepUpTo = typeof declared === 'number' ? declared : 0;
const txnIdx = globalIndexToTxnIndex.get(i) ?? 0;
const prev = txnReplayWindow.get(r.txnId);
// 多个保存点回滚:保留上界取**最早**的(回到最早的保存点),
// 丢弃起点取**最后一个**标记的事务内下标。
txnReplayWindow.set(r.txnId, {
keepUpTo: prev ? Math.min(prev.keepUpTo, keepUpTo) : keepUpTo,
dropFrom: txnIdx,
});
}
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
for (let i = 0; i < allRecords.length; i++) {
const r = allRecords[i];
if (r.txnId === 0 || committedTxns.has(r.txnId)) {
if (r.type === WALRecordType.SAVEPOINT_ROLLBACK) continue; // 标记记录,无数据
// 事务内落在 [start, end) 之外的记录:属于被 savepoint 回滚掉的部分,丢弃
const win = txnReplayWindow.get(r.txnId);
if (win !== undefined) {
const txnIdx = globalIndexToTxnIndex.get(i) ?? 0;
// 保留 [0, keepUpTo);丢弃 [keepUpTo, dropFrom);标记之后的记录照常保留
if (txnIdx >= win.keepUpTo && txnIdx < win.dropFrom) continue;
}
if (r.type === WALRecordType.DROP_TABLE) {
// v0.3.3: DROP_TABLE 回放(异步:需预加载 SSTable 后清除残留数据)
await this.applyDropTableRecovery(r.tableName);
} else {
this.applyWALRecord(r);
}
}
}
// v0.3.3: 恢复完成后将回放数据落盘并截断 WAL,
// 避免每次重启重复回放 + WAL 无限膨胀。
//
// v0.8.0(B-6)顺序固定为:**数据落盘 → manifest 提交 → 才允许截断 WAL**。
// 每一步都有明确的失败语义:
// - flush 失败 → `ARIA_BACKGROUND_ERROR` 抛出(不截断 WAL,下次打开还能重放);
// - manifest 提交失败 → 同样不截断(回放数据仍可从 WAL 重建)。
// 另外:二级索引数据**不在 WAL 里**(回放只写主 LSM),因此重建索引后必须
// 先把索引 LSM 也落盘并提交,才能截断 WAL —— 否则崩溃后索引 memtable 丢失、
// WAL 又为空(不触发重建)→ 索引静默变空(v0.4.2 的同类缺陷)。
if (allRecords.length > 0) {
await this.flushAllLsms();
// v0.4.2-fix: WAL 回放只更新主 LSM,二级索引 LSM 未同步 →
// 崩溃前最后一批写入的索引缺失,重开时索引查询丢行。
// 恢复后全量重建所有表的二级索引(幂等)。
for (const tableName of this.schemas.keys()) {
await this.reindexTableInternal(tableName);
}
await this.flushAllLsms();
await this.advanceWalCheckpoint();
} else if (this.hasPendingFlushData()) {
// 没有回放记录但有未落盘数据(例如 manifest 已提交、内存态来自迁移):
// 同样走完整顺序,避免"内存有数据而 WAL 已被截断"
await this.flushAllLsms();
await this.advanceWalCheckpoint();
}
// 7. 冻结表意图校验:manifest 声称有未落盘冻结表,但 WAL 已被截断/丢失 →
// 这些"已确认写入"真的没了,必须报错而不是安静地少一批行。
this.verifyFrozenIntentsAfterRecovery(allRecords.length);
// 8. Checkpoint Manager(接入 WAL 大小阈值)
// v0.4.2-fix: 事务活跃时 checkpoint 不得截断 WAL —
// 否则 BEGIN/INSERT 记录被截断,COMMIT 后崩溃恢复丢失整个事务数据
//
// v0.8.0B-6/55):checkpoint 只等 **memtable 落盘**,不再等 compaction。
// 此前 `lsm.flush()` 会排空整条链(含 compaction 级联),写路径每 1000 次操作
// 就要等完整 compaction —— 这正是 v0.6.1 记录的"8~11s 悬崖"的另一半。
this.checkpointManager = new CheckpointManager(
this.lsm,
{
checkpoint: async () => {
if (this.currentTxnId) return;
// WAL 只在"数据已落盘 + manifest 已提交"之后才截断(见 advanceWalCheckpoint
await this.advanceWalCheckpoint();
},
flush: async () => {
if (this.currentTxnId) return;
await this.wal.flush();
},
getBufferedBytes: () => this.wal.getBufferedBytes(),
getBufferedCount: () => this.wal.getBufferedCount(),
} as unknown as WAL,
{
flushAll: async () => {
// v0.6.1-fix: checkpoint 必须同时落盘二级索引 LSM —
// 此前只 flush 主 LSMcheckpoint 截断 WAL 后崩溃时索引 memtable 未落盘、
// WAL 为空跳过重建 → 二级索引静默丢失最后一批条目(生产数据一致性问题)
await this.flushAllLsms();
},
// v0.8.0B-6/55):周期 checkpoint 只落 memtable,不等 compaction
flushMemtables: async () => {
await this.flushAllLsms(true);
},
} as any,
this.config.checkpointInterval,
this.config.walSizeThreshold,
);
this.opened = true;
}
async close(): Promise<void> {
if (!this.opened) return;
// v0.8.0(B-6):关闭顺序 = 全部数据落盘 → manifest 提交 → 才截断 WAL。
// 每一步失败都不会导致"WAL 被截断而数据没落盘"。
//
// v0.8.0:整体加 try/finally —— 落盘失败(如介质故障)时**仍然要**关闭后端、
// 释放 Web Locks 并清空运行期状态,然后把错误抛给调用方。修复前只要 flush
// 抛错,后面每一步都不会执行:库锁永久占着、后端不关、内存状态残留
//(计划"生命周期与 API 面"清单里的 `close()` 无 try/finally 项)。
let failure: unknown = null;
try {
await this.flushAllLsms();
// v0.4.5: 页面化存储 — 落盘全部脏页(save 已逐页落盘,此处兜底)
await this.bufferPool.flushAll();
await this.advanceWalCheckpoint();
} catch (error) {
failure = error;
} finally {
try {
await this.backend.close();
} catch (error) {
if (failure === null) failure = error;
}
// v0.4.5: 释放多标签页独占锁(等待锁真正归还)
if (this.dbLock) {
try {
await this.dbLock.release();
} catch { /* 锁释放失败不覆盖已有错误 */ }
this.dbLock = null;
}
// v0.4.2-fix: 清空运行期状态(此前 close 后 mvcc/txn 残留,
// 重开时 beginTransaction 报 TX_ACTIVE 或读到陈旧快照)
this.schemas.clear();
this.tablePKs.clear();
this.secondaryIndexes.clear();
this.uniqueIndexCols.clear();
this.mvcc = new MVCCManager();
this.currentTxnId = null;
this.txnSnapshot = null;
this.savepoints.clear();
this.opCounter = 0;
this.opened = false;
}
if (failure !== null) throw failure;
}
/**
* v0.4.2-fix: 崩溃恢复/自愈 — 校验并移除损坏 SSTable、截断 WAL、重建二级索引。
* v0.4.5 增强:清理 OPFS 残留临时文件、清理孤儿页面(meta 未引用的 pg_ 文件)。
* 应用层检测到异常后调用,无需删库重建。
*
* v0.8.0B-6):孤儿回收**必须**以"manifest 健康"为前提。
* 修复前 `cleanupOrphanPages` 只读裸 JSON meta,读不出来就当"没有任何引用",
* 于是元数据损坏时 repair 会把全部活页删掉(不可逆)。现在:
* - 只要本次打开出现过损坏世代/被丢弃的 SSTable → 直接跳过回收并告警;
* - 引用集合来自 manifest 的权威 meta 列表。
*/
async repair(): Promise<void> {
this.ensureOpen();
// v0.6.0-fix: 先清页面缓存再校验 — 缓存中的"完好页面"会掩盖磁盘损坏
await this.bufferPool.clear();
// 1. 校验全部 SSTable,移除残缺项(打开时已做一次,此处兜底运行期损坏)
let removed = 0;
for (const lsm of this.allLsms()) {
removed += await lsm.validateAll();
}
// 2. 将 WAL 残留数据落盘并提交,之后才允许截断
await this.flushAllLsms();
await this.advanceWalCheckpoint();
// 3. 重建所有表的二级索引(修复索引与主数据不一致)
for (const tableName of this.schemas.keys()) {
await this.reindexTable(tableName);
}
await this.flushAllLsms();
await this.commitManifest();
// v0.8.0(B-6):此时两条链都已静默 → 没有在途读者 → 强制回收退休文件,
// 避免它们被下一步的孤儿页回收"顺带"删掉(结果相同,但状态更干净)
for (const lsm of this.allLsms()) lsm.reclaimRetiredNow();
// v0.4.5: 4. 清理 OPFS 残留临时文件(.crswap/.tmp
const backendAny = this.backend as unknown as { cleanupStaleFiles?: () => Promise<void> };
if (typeof backendAny.cleanupStaleFiles === 'function') {
try { await backendAny.cleanupStaleFiles(); } catch { /* 清理失败不阻塞 */ }
}
// v0.4.5: 5. 清理孤儿页面(manifest 全部命名空间均未引用的 pg_ 文件)
await this.cleanupOrphanPages();
if (removed > 0) {
// eslint-disable-next-line no-console
console.warn(`[AriaEngine] repair: removed ${removed} corrupted SSTable(s)`);
}
}
/**
* v0.4.5: 清理孤儿页面 — 扫描全部 pg_* 文件,未被任何命名空间 meta 引用的删除。
* 孤儿页面来自:崩溃中断的 compaction/删除流程(旧 SSTable 页面残留)。
*
* v0.8.0B-6):引用集合取自 manifest;且**只有在没有损坏迹象时才执行** ——
* "任何引用不到的东西一律保留而非删除"在恢复路径上是不变量,只有显式 repair
* 且 manifest 完整可信时才允许回收空间。
*/
private async cleanupOrphanPages(): Promise<void> {
const damage = this.describeRecoveryDamage();
if (damage.length > 0) {
// eslint-disable-next-line no-console
console.warn(
`[AriaEngine] repair: skipping orphan reclamation — recovery shows damage (${damage.join('; ')}); ` +
'unreferenced data is kept, never deleted blindly',
);
return;
}
// v0.8.0review 修复):**有在途读者时一律不回收**。
// 读者的快照可能持有已被 compaction 取代("退休")的文件;那些文件的页面
// 既不在 manifest、也不在 levels 里 —— 按"没人引用"删掉它们会让正在进行中的
// 扫描静默少数据。回收只能在没有读者时做。
if (this.allLsms().some((lsm) => lsm.hasActiveReaders())) {
// eslint-disable-next-line no-console
console.warn('[AriaEngine] repair: skipping orphan reclamation — readers are active');
return;
}
const keys = await this.backend.listKeys();
// ---- 被引用的页面 idmanifest + 各层内存视图 + 退休表 ----
const used = new Set<number>();
for (const state of Object.values(this.manifest.namespaces)) {
for (const m of state.sstables) {
if (m.pageIds) for (const pid of m.pageIds) used.add(pid);
}
}
for (const lsm of this.allLsms()) {
// 未落盘的页面(正在写入的 SSTable)不能删
for (const level of this.getLsmLevels(lsm)) {
for (const meta of level) {
if (meta.pageIds) for (const pid of meta.pageIds) used.add(pid);
}
}
// 退休但尚未物理删除的 SSTable 的页面同样不能删
for (const pid of lsm.getRetiredPageIds()) used.add(pid);
}
const pgKeys = keys.filter((k) => /^pg_\d+$/.test(k));
const orphanIds = pgKeys
.map((k) => Number(k.slice('pg_'.length)))
.filter((pid) => !used.has(pid));
if (orphanIds.length > 0) {
await this.backend.deleteMany(orphanIds.map((pid) => `pg_${pid}`));
// eslint-disable-next-line no-console
console.warn(`[AriaEngine] repair: reclaimed ${orphanIds.length} orphan page file(s)`);
}
// ---- 孤儿 SSTable 文件(整 value 路径:sst_<ns>_<id> / sst_<id>----
// 退休 SSTable 的物理删除是"尽力而为":删除失败/崩溃会留下既不被 manifest
// 引用、也不在任何层里的文件。页面化路径由上面的 pg_ 回收覆盖;
// 整 value 路径(pageStorage:false / 旧库 / 迁移数据)此前**没有任何回收路径**。
const usedSstIds = new Set<number>();
for (const state of Object.values(this.manifest.namespaces)) {
for (const m of state.sstables) usedSstIds.add(m.id);
}
for (const lsm of this.allLsms()) {
for (const level of this.getLsmLevels(lsm)) {
for (const meta of level) usedSstIds.add(meta.id);
}
for (const id of lsm.getRetiredSstableIds()) usedSstIds.add(id);
}
const orphanSstKeys: string[] = [];
for (const [ns, prefix] of this.sstableKeyPrefixes()) {
const re = new RegExp(`^${prefix}(\\d+)$`);
for (const key of keys) {
const m = re.exec(key);
if (!m) continue;
if (!usedSstIds.has(Number(m[1]))) orphanSstKeys.push(key);
}
void ns;
}
if (orphanSstKeys.length > 0) {
await this.backend.deleteMany(orphanSstKeys);
// eslint-disable-next-line no-console
console.warn(`[AriaEngine] repair: reclaimed ${orphanSstKeys.length} orphan SSTable file(s)`);
}
}
/** v0.8.0:命名空间 → SSTable 文件 key 前缀(与 createSSTableStore 保持一致) */
private sstableKeyPrefixes(): [string, string][] {
const out: [string, string][] = [['main', 'sst_']];
for (const ns of Object.keys(this.manifest.namespaces)) {
if (ns !== 'main') out.push([ns, `sst_${ns}_`]);
}
return out;
}
/** v0.8.0: 读取某个 LSM 当前引用的层结构(诊断/孤儿回收用) */
private getLsmLevels(lsm: LSM): SSTableMeta[][] {
return (lsm as unknown as { levels: SSTableMeta[][] }).levels ?? [];
}
/**
* v0.4.1: 重置数据库 — 清空全部数据与表结构(演示页刷新/重新初始化用)。
* 清空存储后端、LSM、WAL、MVCC 与二级索引,后续可继续使用本实例。
*/
async clearAll(): Promise<void> {
this.ensureOpen();
// 清空存储后端(页面文件 / WAL 记录 / schema 记录 / 元数据)
await this.backend.clear();
// v0.4.5: 清空页面缓存与页面 ID 分配状态
await this.bufferPool.clear();
await this.fileManager.clearAll();
this.schemas.clear();
this.tablePKs.clear();
this.secondaryIndexes.clear();
this.uniqueIndexCols.clear();
this.lsm.clear();
this.mvcc = new MVCCManager();
this.currentTxnId = null;
this.txnSnapshot = null;
this.savepoints.clear();
this.opCounter = 0;
// 持久化空 schema(防止旧 schema 记录残留)
await this.persistSchemas();
// 重置 WAL 状态(backend.clear 已清记录,同步内存计数)
// v0.8.0B-6):清空后 manifest 里不能再残留任何 meta —— 否则重开会引用
// 已被删除的页面(幽灵数据)。这里把命名空间状态整表清掉并提交。
this.manifest.namespaces = {};
this.manifest.frozen = [];
this.manifest.pageIdWatermark = Math.max(this.manifest.pageIdWatermark, this.fileManager.getNextPageId());
this.durableLsn = this.wal.getLsn();
this.walStartSegment = 0;
// 整库清空:介质被整体抹掉、manifest 也重新从 0 开始 —— 此时才允许
// 把 WAL 分片编号也重置(其它任何路径都不允许复用分片号)
this.wal.reset();
await this.commitManifest();
await this.wal.checkpoint();
}
isOpen(): boolean { return this.opened; }
// ---- v0.4.2-fix: 库内元数据(迁移版本持久化用) ----
async getMeta(key: string): Promise<string | null> {
const raw = await this.backend.read(`__meta_${key}`);
return raw ? new TextDecoder().decode(raw) : null;
}
async setMeta(key: string, value: string): Promise<void> {
await this.backend.write(`__meta_${key}`, new TextEncoder().encode(value).buffer);
}
// =======================================================================
// 表管理
// =======================================================================
async createTable(schema: TableSchema): Promise<void> {
this.ensureOpen();
// v0.4.2-fix: Aria 事务中 DDL 显式拒绝(事务快照只覆盖行数据,
// 结构变更无法回滚;Memory/IndexedDB 引擎快照可回滚,行为不一致 → 明确报错而非静默)
this.ensureNoDDLInTransaction('CREATE TABLE');
if (this.schemas.has(schema.name)) {
throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS');
}
// v0.8.0A41):**先写 WAL 意图,再改内存/落盘**。
//
// 此前顺序是"改内存 → persistSchemas → 追加 WAL",中间任何一步失败或崩溃,
// 这次 DDL 都只留下一半状态。DROP 侧的顺序问题已实测确认:
// dropTable 删完 LSM 与 schema、但 WAL 记录未写成时崩溃 →
// 重开后表**又回来了**(数据也还在),DROP 被静默撤销。
//
// WAL 是权威来源,且回放是幂等的(`applyWALRecord` 对已存在的表跳过、
// `applyDropTableRecovery` 对不存在的表是空操作),因此"先写 WAL"总能收敛:
// - 崩溃于 WAL 之后、生效之前 → 恢复时重放,DDL 生效 ✓
// - 崩溃于生效之后 → 恢复时重放,幂等 ✓
await this.appendDDLRecord({
type: WALRecordType.CREATE_TABLE,
txnId: 0,
tableName: schema.name,
key: '',
data: { schema: JSON.stringify(schema) } as unknown as Record<string, unknown>,
});
this.schemas.set(schema.name, schema);
this.tablePKs.set(schema.name, this.getPK(schema));
// 为索引列创建二级索引 LSM(每个索引使用独立命名空间的 SSTableStore,避免 id/meta 冲突)
// v0.3.3: 主键列不建冗余二级索引(主 LSM 本身就是 PK 索引,范围查询走前缀扫描)
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (colDef.index || colDef.unique) {
const idxKey = `${schema.name}:idx:${colName}`;
if (!this.secondaryIndexes.has(idxKey)) {
const idxLsm = new LSM({
memtableSizeThreshold: this.config.memtableSizeThreshold,
levelSizeMultiplier: this.config.levelSizeMultiplier,
blockSize: this.config.pageSize,
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize,
sstableStore: this.createSSTableStore(`idx_${schema.name}_${colName}`),
});
await idxLsm.init();
this.secondaryIndexes.set(idxKey, idxLsm);
}
}
}
await this.persistSchemas();
}
/**
* v0.8.0A41):追加一条 DDL 意图记录并立即刷盘。
*
* 为什么独立成函数:两条 DDL 路径必须共用同一套顺序与刷盘策略,
* 否则将来只改一处又会漂移 —— 这正是本项目反复出现的缺陷模式。
*
* DDL 不参与事务(`ensureNoDDLInTransaction` 已保证),因此 txnId 恒为 0,
* 不需要提交/回滚语义;但**必须先于生效**写入,否则崩溃会静默丢失 DDL。
* DDL 是低频操作,这里同步刷盘,避免"崩溃丢失 DDL"的窗口过大。
*/
private async appendDDLRecord(record: Omit<WALRecord, 'lsn' | 'checksum'>): Promise<void> {
await this.wal.append(record);
await this.wal.flush();
}
async dropTable(tableName: string): Promise<void> {
this.ensureOpen();
this.ensureNoDDLInTransaction('DROP TABLE');
this.ensureTable(tableName);
// v0.8.0A41):同 createTable —— **先写 WAL 意图**。
// 实测修复前:dropTable('other') 之后崩溃 → 重开 `tables = ["t","other"]`
// 且 other 的行数据完好,DROP 被静默撤销(用户以为删掉了)。
await this.appendDDLRecord({
type: WALRecordType.DROP_TABLE,
txnId: 0,
tableName,
key: '',
});
// 删除表中所有行
const rows = await this.getAllRows(tableName);
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
this.lsm.delete(`${tableName}:${row[pkCol]}`);
}
// v0.4.2-fix: 清理该表的全部二级索引 LSM 与持久化文件 —
// 此前残留孤儿索引,重建同名表后旧索引数据污染新表(索引查询返回错误行)
await this.cleanupTableIndexes(tableName);
// v0.7.4: 清理该表的 unique 索引来源标记
const uPrefix = `${tableName}:`;
for (const k of this.uniqueIndexCols) {
if (k.startsWith(uPrefix)) this.uniqueIndexCols.delete(k);
}
this.schemas.delete(tableName);
this.tablePKs.delete(tableName);
await this.persistSchemas();
}
/**
* v0.4.2-fix: 清理指定表的全部二级索引 LSM(内存 + 存储文件 + meta)。
* dropTable / DROP_TABLE 恢复 / alterTable DROP 索引列 共用。
*/
private async cleanupTableIndexes(tableName: string): Promise<void> {
const prefix = `${tableName}:idx:`;
const toDelete: string[] = [];
for (const [idxKey, idxLsm] of this.secondaryIndexes) {
if (!idxKey.startsWith(prefix)) continue;
toDelete.push(idxKey);
try {
await idxLsm.clear();
} catch { /* 清理失败不阻塞 */ }
}
for (const idxKey of toDelete) {
this.secondaryIndexes.delete(idxKey);
}
}
async hasTable(tableName: string): Promise<boolean> {
// v0.7.1: 未 open 防护统一(此前与 KVStoreEngine 不一致:返回空而非报错)
this.ensureOpen();
return this.schemas.has(tableName);
}
async getTableNames(): Promise<string[]> {
this.ensureOpen();
return Array.from(this.schemas.keys());
}
async getTableSchema(tableName: string): Promise<TableSchema | null> {
this.ensureOpen();
return this.schemas.get(tableName) ?? null;
}
// =======================================================================
// CRUD
// =======================================================================
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
this.ensureOpen();
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const pkCol = this.tablePKs.get(tableName)!;
const pks: string[] = [];
// v0.3.1: 批量 WAL 写入(组提交),一次 insert 合并为一次落盘
const walRecords: Omit<WALRecord, 'lsn' | 'checksum'>[] = [];
// v0.6.1-perf: 批量预加载本批 PK 涉及的 SSTable(一次 drainChain)。
// 此前循环内逐行 prefetchKeys —— 每行 await drainChain 排空后台链,
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
// 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
// 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。
//
// v0.6.2: 整批预校验(验证失败整批不落库,语义更原子)+ 唯一约束检查。
const uniqueCols = this.uniqueColumns(tableName, schema);
const validatedRows: { row: Record<string, unknown>; pkValue: string; key: string }[] = [];
for (const row of rows) {
const validated = this.validateRow(schema, row);
const pkValue = String(validated[pkCol]);
validatedRows.push({ row: validated, pkValue, key: `${tableName}:${pkValue}` });
}
// v0.7.3: 主键批内互查 + 预检 —— 此前 PK 重复检查在写入循环内:
// 第 N 行重复抛错时,前 N-1 行已 put LSM 且其 WAL 记录随 appendBatch 一起
// 丢失 → 语句级部分提交 + 内存/WAL 不一致(与 v0.6.2 的 unique 预检同一阶段)。
const pkSet = new Set<string>();
for (const { pkValue, key } of validatedRows) {
if (pkSet.has(pkValue)) {
throw new DatabaseError(
`Duplicate primary key "${pkValue}" in table "${tableName}"`,
'DUPLICATE_KEY',
);
}
pkSet.add(pkValue);
// v0.8.0: 事务快照优先,否则回源 LSMlsm.get 现为 async
const existing = this.currentTxnId
? (this.txnSnapshot?.get(key) ?? await this.lsm.get(key))
: await this.lsm.get(key);
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
throw new DatabaseError(
`Duplicate primary key "${pkValue}" in table "${tableName}"`,
'DUPLICATE_KEY',
);
}
}
// v0.8.0B-6/55):唯一约束预检不再需要"批量预加载索引范围"。
//
// 修复前这里要遍历本批所有唯一列值、把涉及的范围全部 prefetch 进缓存,
// 只为满足"读之前必须先把数据读进缓存"这条隐式约定(LSM 缓存未命中会
// 静默跳过整个 SSTable)。现在读取自洽(未命中即回源 + CRC 校验),
// 预加载这一步连同它带来的 drainChain 等待一起删除。
// v0.6.2: 唯一性整批预检(批内互查 + 索引查)—— 失败整批不落库(原子语义)
const batchUnique = new Map<string, Set<string>>();
for (const { row: validated, pkValue } of validatedRows) {
for (const colName of uniqueCols) {
const val = validated[colName];
if (val === undefined || val === null) continue;
const v = String(val);
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set<string>();
batchUnique.set(colName, seen);
}
if (seen.has(v)) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${tableName}"`,
'UNIQUE_VIOLATION',
);
}
seen.add(v);
await this.checkUnique(tableName, [colName], validated, pkValue);
}
}
for (const { row: validated, pkValue, key } of validatedRows) {
// PK 重复已在批预检阶段检查(v0.7.3),此处不再重复查询
if (this.currentTxnId && this.txnSnapshot) {
// Within transaction: buffer to snapshot + MVCC version chain
this.txnSnapshot.set(key, validated);
this.mvcc.writeVersion(tableName, pkValue, validated, this.currentTxnId);
} else {
// Direct write to LSM (PK index)
this.lsm.put(key, validated);
}
// 更新二级索引
this.updateSecondaryIndexes(tableName, pkValue, validated, null);
pks.push(pkValue);
walRecords.push({
type: WALRecordType.INSERT,
txnId: this.currentTxnId ?? 0,
tableName,
key: pkValue,
data: validated,
});
}
await this.wal.appendBatch(walRecords);
this.txnWalRecordCount += walRecords.length;
this.opCounter += rows.length;
this.checkMemoryBudget();
await this.checkpointManager.tick();
this.tryGC();
return pks;
}
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
this.ensureOpen();
this.ensureTable(tableName);
let rows: Record<string, unknown>[];
// Try index lookup
const fastPath = await this.tryIndexLookup(tableName, query);
if (fastPath !== null) {
rows = fastPath;
} else {
rows = await this.getAllRows(tableName);
}
// v0.3.3: 事务内合并未提交快照(统一在 mergeTxnSnapshot 处理)
rows = this.mergeTxnSnapshot(tableName, rows);
// WHERE filter
if (query.where && Object.keys(query.where).length > 0) {
rows = rows.filter((row) => matchWhere(row, query.where!));
}
// ORDER
if (query.orderBy && query.orderBy.length > 0) {
rows = applyOrderBy(rows, query.orderBy);
}
// LIMIT/OFFSET
const offset = query.offset ?? 0;
const limit = query.limit ?? rows.length;
rows = rows.slice(offset, offset + limit);
// Column projection
if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
rows = rows.map((row) => projectColumns(row, query.columns!));
}
// 查询完成,回收查询期间的临时缓存超限
this.trimAllCaches();
// v0.8.0: 行所有权 —— 返回副本,调用方不得改写存储(见 engine/interface.ts 约定)
return rows.map((row) => cloneRow(row));
}
async update(
tableName: string,
query: QueryPlan,
updates: Record<string, unknown>,
): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
// v0.8.0B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。
//
// 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析,
// 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。
// B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能
// 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的
// 补救方向:用户没有做错任何事。
//
// 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉,
// 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。
const schema = this.schemas.get(tableName)!;
const rows = await this.getAllRows(tableName);
let count = 0;
// v0.3.1: 批量 WAL 写入(组提交)
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
// v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set<string>();
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates);
// v0.7.4: 未知列显式报错 —— 此前 SET nonexistent = ... 被静默写入存储行
// validateRow 只遍历 schema 列,脏列残留在行内并随 SSTable 持久化)
for (const col of Object.keys(cleanUpdates)) {
if (!schema.columns[col]) {
throw new DatabaseError(`Column "${col}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
}
}
// v0.8.0(B-6/55):唯一约束检查不再需要预加载索引范围(读取自洽,见 insert)
const uniqueCols = this.uniqueColumns(tableName, schema);
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// 且其 WAL 记录随 appendBatch 一起丢失 → 内存已改、WAL 无记录、调用方已收到错误
// (无事务下语句级部分提交 + 崩溃后进一步不一致)。
const planned: { row: Record<string, unknown>; pk: string; key: string; updated: Record<string, unknown>; newPk: string; pkChanged: boolean }[] = [];
const batchUnique: Map<string, Set<unknown>> = new Map();
/**
* v0.8.0 根治:批内新主键互查(与 MemoryEngine 对齐)。
*
* 此前只检查"新主键是否已存在于**语句执行前**的表",看不到同一语句内其它行
* 即将写入的新主键。于是 `UPDATE t SET id = 'X'`(匹配 3 行)在阶段 2 逐行
* 覆盖同一 LSM key —— 返回 affected=3,表中却只剩 1 行(静默丢行,实测)。
*/
const batchNewPks = new Set<string>();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
const key = `${tableName}:${row[pkCol]}`;
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where)) continue;
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
await this.checkUnique(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? await this.lsm.get(newKey))
: await this.lsm.get(newKey);
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
throw new DatabaseError(
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
'DUPLICATE_KEY',
);
}
// v0.8.0: 批内互查 —— 同一语句内两行改到同一新主键 → 整体拒绝(不得静默覆盖)
if (batchNewPks.has(newPk)) {
throw new DatabaseError(
`Duplicate primary key "${newPk}" in table "${tableName}" (multiple rows in the same statement update to the same key)`,
'DUPLICATE_KEY',
);
}
batchNewPks.add(newPk);
}
planned.push({ row, pk: String(row[pkCol]), key, updated, newPk, pkChanged });
}
// 阶段 1b:主键变更 RESTRICT / SET NULL+required 预检(任何修改前)
for (const p of planned) {
if (p.pkChanged) {
await this.checkForeignKeyUpdateRestrict(tableName, p.pk, p.newPk);
}
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
for (const { row, pk, key, updated, newPk, pkChanged } of planned) {
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
} else {
if (pkChanged) this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: pk,
});
}
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName,
key: newPk,
data: updated,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
await this.wal.appendBatch(walRecords);
this.txnWalRecordCount += walRecords.length;
this.opCounter += count;
await this.checkpointManager.tick();
this.trimAllCaches();
return count;
}
/**
* v0.7.2: 批内唯一互查 — 两条行在同一语句中更新到同一唯一值时的兜底检查
* (阶段 1 中索引尚未反映本语句的变更)。
*/
private checkBatchUnique(
tableName: string,
uniqueCols: string[],
updated: Record<string, unknown>,
batchUnique: Map<string, Set<unknown>>,
): void {
for (const colName of uniqueCols) {
const value = updated[colName];
if (value === undefined || value === null) continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set<unknown>();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${tableName}"`,
'UNIQUE_VIOLATION',
);
}
seen.add(value);
}
}
/**
* v0.7.2: ON UPDATE 外键预检 — 从 applyForeignKeyUpdateRules 提取(两阶段 update 用):
* RESTRICT 存在依赖行抛错;SET NULL 撞 required 列同样整体拒绝。
*/
private async checkForeignKeyUpdateRestrict(tableName: string, oldPk: string, _newPk: string): Promise<void> {
for (const [refTableName, refSchema] of this.schemas) {
// v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
if (colDef.onUpdate === 'RESTRICT' || (colDef.onUpdate === 'SET NULL' && colDef.required)) {
const refRows = await this.getAllRows(refTableName);
for (const refRow of refRows) {
if (String(refRow[colName]) === oldPk) {
const reason = colDef.onUpdate === 'RESTRICT'
? `foreign key "${colName}" in "${refTableName}" has dependent rows`
: `foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`;
throw new DatabaseError(
`Cannot update "${tableName}" key "${oldPk}": ${reason}`,
'FOREIGN_KEY_VIOLATION',
);
}
}
}
}
}
}
/**
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
* 两阶段:先全量 RESTRICT 检查,再执行级联。
*/
private async applyForeignKeyUpdateRules(
tableName: string,
oldPk: string,
newPk: string,
walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[],
visited: Set<string>,
): Promise<void> {
const visitKey = `${tableName}:${oldPk}`;
if (visited.has(visitKey)) return;
visited.add(visitKey);
// v0.7.3-perf: 删除冗余的阶段 1 RESTRICT 扫描 —— checkForeignKeyUpdateRestrict
// 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required
// 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。
for (const [refTableName, refSchema] of this.schemas) {
// v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
if (colDef.onUpdate !== 'CASCADE' && colDef.onUpdate !== 'SET NULL') continue;
const refRows = await this.getAllRows(refTableName);
for (const refRow of refRows) {
if (String(refRow[colName]) !== oldPk) continue;
const refPkCol = this.tablePKs.get(refTableName)!;
const refPk = String(refRow[refPkCol]);
const updatedRef = { ...refRow, [colName]: colDef.onUpdate === 'CASCADE' ? newPk : null };
const refKey = `${refTableName}:${refPk}`;
if (this.currentTxnId && this.txnSnapshot) {
this.txnSnapshot.set(refKey, updatedRef);
this.mvcc.writeVersion(refTableName, refPk, updatedRef, this.currentTxnId);
} else {
this.lsm.put(refKey, updatedRef);
}
this.updateSecondaryIndexes(refTableName, refPk, updatedRef, refRow);
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName: refTableName,
key: refPk,
data: updatedRef,
});
}
}
}
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
// v0.8.0B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。
//
// 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析,
// 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。
// B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能
// 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的
// 补救方向:用户没有做错任何事。
//
// 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉,
// 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。
const rows = await this.getAllRows(tableName);
let count = 0;
// v0.3.1: 批量 WAL 写入(组提交)
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
// v0.4.1: 外键级联(环路保护)
const visited = new Set<string>();
// v0.6.3-fix: 级联两阶段 —— 先对全部匹配行做 RESTRICT 预检(沿 CASCADE 链递归),
// 否则第 N 行 RESTRICT 抛错时前 N-1 行的级联已执行 → 无事务部分级联(数据不一致)
const matchedPks: string[] = [];
for (const row of rows) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
matchedPks.push(String(row[this.tablePKs.get(tableName)!]));
}
}
const restrictVisited = new Set<string>();
for (const pkValue of matchedPks) {
await this.checkCascadeRestrict(tableName, pkValue, restrictVisited);
}
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
const key = `${tableName}:${row[pkCol]}`;
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.4.1: 外键规则(RESTRICT 抛错 / CASCADE 递归删 / SET NULL 置空)
count += await this.applyForeignKeyRules(tableName, String(row[pkCol]), walRecords, visited);
if (this.currentTxnId && this.txnSnapshot) {
// Buffer delete in snapshot + MVCC tombstone
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
} else {
this.lsm.delete(key);
}
count++;
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
});
// 移除二级索引
this.updateSecondaryIndexes(tableName, String(row[pkCol]), null, row);
}
}
await this.wal.appendBatch(walRecords);
this.txnWalRecordCount += walRecords.length;
this.opCounter += count;
await this.checkpointManager.tick();
this.trimAllCaches();
return count;
}
/**
* v0.6.3: RESTRICT 预检(delete 级联两阶段之一,与 MemoryEngine 对齐)。
* 递归沿 CASCADE 链检查引用表:RESTRICT 引用存在依赖行则抛 FOREIGN_KEY_VIOLATION。
*/
private async checkCascadeRestrict(tableName: string, pkValue: string, visited: Set<string>): Promise<void> {
const visitKey = `${tableName}:${pkValue}`;
if (visited.has(visitKey)) return;
visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) {
// v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
const refRows = await this.getAllRows(refTableName);
const matched = refRows.filter((r) => String(r[colName]) === pkValue);
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
throw new DatabaseError(
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION',
);
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
if (colDef.onDelete === 'SET NULL' && colDef.required && matched.length > 0) {
throw new DatabaseError(
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`,
'FOREIGN_KEY_VIOLATION',
);
}
if (colDef.onDelete === 'CASCADE') {
const refPkCol = this.tablePKs.get(refTableName)!;
for (const refRow of matched) {
await this.checkCascadeRestrict(refTableName, String(refRow[refPkCol]), visited);
}
}
}
}
}
/**
* v0.4.1: 外键级联规则 — 对齐 MemoryEngine.cascadeDelete 行为。
* 删除 tableName 主键为 pkValue 的行前,检查引用它的所有表:
* - RESTRICT: 存在引用行 → 抛 FOREIGN_KEY_VIOLATION
* - CASCADE: 递归删除引用行(含索引/WAL)
* - SET NULL: 引用行外键列置 null(含索引/WAL)
* @returns 级联影响的行数(CASCADE 删除行数 + SET NULL 更新行数)
*/
private async applyForeignKeyRules(
tableName: string,
pkValue: string,
walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[],
visited: Set<string>,
): Promise<number> {
let total = 0;
const visitKey = `${tableName}:${pkValue}`;
if (visited.has(visitKey)) return 0;
visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) {
// v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue``parent_id REFERENCES node(id)` 的树形自引用完全不做
// 级联 → `DELETE root` 只删 root,子树全部残留且 parent_id 指向已删除行
//(父行已不在,之后再也无法通过级联清理 —— 永久悬挂)。
// 与 MemoryEngine 的修复同源(两个引擎此前的跳过条件逐字相同)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
// 自引用场景下 `getAllRows` 返回的是当前表的快照副本(cloneRow),
// 因此循环内的删除不会改变 `matched` —— 这正是这里能安全递归的原因。
const refRows = await this.getAllRows(refTableName);
const matched = refRows.filter((r) => String(r[colName]) === pkValue);
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
throw new DatabaseError(
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION',
);
}
if (colDef.onDelete === 'CASCADE') {
const refPkCol = this.tablePKs.get(refTableName)!;
for (const refRow of matched) {
const refPk = String(refRow[refPkCol]);
// 递归级联(先处理更深层引用)
total += await this.applyForeignKeyRules(refTableName, refPk, walRecords, visited);
// 删除引用行
const refKey = `${refTableName}:${refPk}`;
if (this.currentTxnId && this.txnSnapshot) {
this.txnSnapshot.set(refKey, { __txn_deleted: true } as unknown as Record<string, unknown>);
this.mvcc.deleteVersion(refTableName, refPk, this.currentTxnId);
} else {
this.lsm.delete(refKey);
}
this.updateSecondaryIndexes(refTableName, refPk, null, refRow);
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName: refTableName,
key: refPk,
});
total++;
}
} else if (colDef.onDelete === 'SET NULL') {
const refPkCol = this.tablePKs.get(refTableName)!;
for (const refRow of matched) {
const refPk = String(refRow[refPkCol]);
const updated = { ...refRow, [colName]: null };
const refKey = `${refTableName}:${refPk}`;
if (this.currentTxnId && this.txnSnapshot) {
this.txnSnapshot.set(refKey, updated);
this.mvcc.writeVersion(refTableName, refPk, updated, this.currentTxnId);
} else {
this.lsm.put(refKey, updated);
}
this.updateSecondaryIndexes(refTableName, refPk, updated, refRow);
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName: refTableName,
key: refPk,
data: updated,
});
// 对齐 Memory 语义:SET NULL 不影响返回的删除行数
}
}
}
}
return total;
}
/**
* v0.4.0: 流式查询 — 逐行回调,不物化结果数组。
* 全表路径走 LSM rangeScanLazy 惰性扫描;索引等值/范围路径复用 tryIndexLookup。
* 事务中回退物化(快照合并需要全量行集)。
*/
async findStream(
tableName: string,
query: QueryPlan,
onRow: (row: Record<string, unknown>) => void,
): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
const hasWhere = !!(query.where && Object.keys(query.where).length > 0);
const project = query.columns && query.columns.length > 0 && query.columns[0] !== '*'
? (row: Record<string, unknown>) => projectColumns(row, query.columns!)
: null;
const limit = query.limit ?? Infinity;
const offset = query.offset ?? 0;
const pkCol = this.tablePKs.get(tableName)!;
const prefix = `${tableName}:`;
let count = 0;
let skipped = 0;
const emit = (row: Record<string, unknown>): boolean => {
if (hasWhere && !matchWhere(row, query.where!)) return true;
if (skipped < offset) { skipped++; return true; }
onRow(project ? project(row) : cloneRow(row));
count++;
return count < limit;
};
if (this.currentTxnId && this.txnSnapshot) {
// 事务中:物化后逐行回调(快照合并需要全量行集)
const rows = await this.find(tableName, { ...query, orderBy: undefined, limit: undefined, offset: undefined });
for (const row of rows) {
onRow(project ? project(row) : cloneRow(row));
}
return rows.length;
}
// 索引路径:等值/范围查找(结果行已过滤,直接回调)
const fastPath = await this.tryIndexLookup(tableName, query);
if (fastPath !== null) {
for (const row of fastPath) {
if (!emit(row)) break;
}
return count;
}
// 全表惰性扫描(含 WHERE 过滤,不物化;v0.7.4: callback 返回 false 提前终止,
// 未消费的 SSTable 块 / 子树不再解析 —— 真流式,大表 limit 内存 O(1)
await this.lsm.rangeScanLazy(prefix, `${prefix}\uffff`, (key, value) => {
if (count >= limit) return false;
const row = { ...value };
row[pkCol] = key.slice(prefix.length);
return emit(row);
});
return count;
}
async count(tableName: string, query?: QueryPlan): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
const rows = await this.getAllRows(tableName);
this.trimAllCaches();
if (!query?.where || Object.keys(query.where).length === 0) return rows.length;
return rows.filter((row) => matchWhere(row, query.where!)).length;
}
async clear(tableName: string): Promise<void> {
this.ensureOpen();
this.ensureTable(tableName);
const rows = await this.getAllRows(tableName);
// v0.3.3: 事务内清空走快照(删除标记),提交时生效;并写入 WAL
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
const key = `${tableName}:${row[pkCol]}`;
if (this.currentTxnId && this.txnSnapshot) {
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
} else {
this.lsm.delete(key);
}
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
});
// 移除二级索引
this.updateSecondaryIndexes(tableName, String(row[pkCol]), null, row);
}
await this.wal.appendBatch(walRecords);
this.txnWalRecordCount += walRecords.length;
this.opCounter += rows.length;
await this.checkpointManager.tick();
this.tryGC();
}
// ---- ALTER TABLEv0.4.1 ----
/**
* v0.4.1: ALTER TABLE — 结构变更真正生效于存储:
* - ADD: 持久化 schemapersistSchemas),行无需修改
* - DROP: 持久化 schema + 遍历主 LSM 重写所有行(移除该列键)+ WAL UPDATE 记录
* (通用路径 getTableSchema 返回副本,Executor 的引用修改对 Aria 无效)
*/
async alterTable(
tableName: string,
action: 'ADD' | 'DROP',
column: import('../../constants').ColumnDef & { name: string },
): Promise<void> {
this.ensureOpen();
this.ensureNoDDLInTransaction('ALTER TABLE');
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
if (action === 'ADD') {
if (schema.columns[column.name]) {
throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS');
}
// v0.8.0A41):先写 ALTER 意图(含**变更后**的完整 schema),再改内存/索引。
//
// 此前完全不写 WAL:先改内存 schema → 建索引(可能因存量重复值抛错)→
// persistSchemas。中间抛错就留下"内存已加列、磁盘没加"的分裂状态 ——
// 同进程 `getTableSchema` 看到新列,重开后新列消失,用户看到的是
// "ALTER 有时生效有时不生效,取决于是否重启"。
// 有了意图记录,崩溃/失败后恢复会按它把结构补齐(幂等覆盖)。
const intendedSchema: TableSchema = {
name: schema.name,
columns: { ...schema.columns, [column.name]: column },
};
await this.appendDDLRecord({
type: WALRecordType.ALTER_TABLE,
txnId: 0,
tableName,
key: column.name,
data: { schema: JSON.stringify(intendedSchema), action: 'ADD' } as unknown as Record<string, unknown>,
});
schema.columns[column.name] = column;
// v0.8.0 根治:ALTER ADD 的索引/唯一列必须真正建立索引 LSM 并回填。
//
// 此前只写 schema + persistSchemas,索引 LSM 从未创建 → `unique` 标记形同虚设,
// 重复值可任意写入(实测四个引擎全部接受);重启时索引才被建出来,与 Memory
// "重启静默丢行"是同一问题的另一半。
//
// 复用 createIndex:它已经实现了"回填 + 存量唯一性校验 + 失败时原子清理"
// v0.6.2/v0.7.3 的修复成果),此处不重复实现以避免再次漂移。
if (column.index || column.unique) {
// createIndex 会读取 schema.columns[column],上面的赋值已满足
await this.createIndex(tableName, column.name, column.unique === true);
}
await this.persistSchemas();
return;
}
// DROP
if (!schema.columns[column.name]) {
throw new DatabaseError(`Column "${column.name}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
}
// v0.4.2-fix: 被删列是索引列 → 先清理索引 LSM(残留会导致后续同名列索引脏数据)
if (schema.columns[column.name].index || schema.columns[column.name].unique) {
const idxKey = `${tableName}:idx:${column.name}`;
const idxLsm = this.secondaryIndexes.get(idxKey);
if (idxLsm) {
try {
await idxLsm.clear();
} catch { /* 清理失败不阻塞 */ }
this.secondaryIndexes.delete(idxKey);
}
}
// v0.8.0A41):DROP 同样先写意图(变更后的完整 schema)
{
const intendedSchema: TableSchema = {
name: schema.name,
columns: Object.fromEntries(
Object.entries(schema.columns).filter(([col]) => col !== column.name),
),
};
await this.appendDDLRecord({
type: WALRecordType.ALTER_TABLE,
txnId: 0,
tableName,
key: column.name,
data: { schema: JSON.stringify(intendedSchema), action: 'DROP' } as unknown as Record<string, unknown>,
});
}
delete schema.columns[column.name];
await this.persistSchemas();
// 重写主 LSM:移除所有行的该列键(find 副本无法就地删除,必须重写存储)
const prefix = `${tableName}:`;
const endKey = `${prefix}\uffff`;
const entries = await this.lsm.rangeScan(prefix, endKey);
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
for (const [key, value] of entries) {
if (!(column.name in value)) continue;
const updated = { ...value };
delete updated[column.name];
this.lsm.put(key, updated);
// 二级索引列被删时同步清理索引
const pk = key.slice(prefix.length);
this.updateSecondaryIndexes(tableName, pk, updated, value);
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName,
key: pk,
data: updated,
});
}
await this.wal.appendBatch(walRecords);
this.txnWalRecordCount += walRecords.length;
this.opCounter += walRecords.length;
await this.checkpointManager.tick();
this.trimAllCaches();
}
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
this.ensureOpen();
this.ensureNoDDLInTransaction('CREATE INDEX');
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
const idxKey = `${tableName}:idx:${column}`;
// v0.4.2-fix: 以索引 LSM 是否已建为准(schema 标记可能因重启恢复而存在,
// 但索引 LSM 未恢复 → 此前静默 return 导致索引永久缺失)
if (this.secondaryIndexes.has(idxKey)) return;
const idxLsm = new LSM({
memtableSizeThreshold: this.config.memtableSizeThreshold,
levelSizeMultiplier: this.config.levelSizeMultiplier,
blockSize: this.config.pageSize,
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize,
sstableStore: this.createSSTableStore(`idx_${tableName}_${column}`),
});
await idxLsm.init();
this.secondaryIndexes.set(idxKey, idxLsm);
try {
// 从主 LSM 重建索引数据
const pkCol = this.tablePKs.get(tableName)!;
const rows = await this.getAllRows(tableName);
const seen = new Set<string>();
for (const row of rows) {
const value = row[column];
if (value !== undefined && value !== null) {
const v = String(value);
// v0.7.3: UNIQUE 索引回填校验存量唯一性 —— 此前重复数据静默建索引
// SQLite 语义应报错),与 MemoryEngine 对齐
if (unique && seen.has(v)) {
throw new DatabaseError(
`Unique index on column "${column}" in table "${tableName}" cannot be created: duplicate value "${v}"`,
'UNIQUE_VIOLATION',
);
}
seen.add(v);
idxLsm.put(`${v}:${row[pkCol]}`, { pk: row[pkCol] });
}
}
await idxLsm.flush();
} catch (error) {
// 回填失败(唯一冲突):清理半初始化索引(内存 + 存储),标志未落,保持原子语义
this.secondaryIndexes.delete(idxKey);
try { await idxLsm.clear(); } catch { /* 清理失败不阻塞 */ }
throw error;
}
colDef.index = true;
if (unique) {
colDef.unique = true;
// v0.7.4: 记录唯一约束来源(DROP INDEX 时可解除;建表约束不可)
this.uniqueIndexCols.add(`${tableName}:${column}`);
}
await this.persistSchemas();
}
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
this.ensureOpen();
this.ensureNoDDLInTransaction('DROP INDEX');
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
// 主键索引不可删除(PK 查找依赖主 LSM)
if (colDef.primaryKey) {
throw new DatabaseError(`Cannot drop primary key index on column "${column}"`, 'NOT_SUPPORTED');
}
// v0.4.1: DROP 不存在的索引应报错(此前静默成功)
if (!colDef.index && !colDef.unique && !this.secondaryIndexes.has(`${tableName}:idx:${column}`)) {
throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND');
}
// v0.7.4: 建表 UNIQUE 约束不可通过 DROP INDEX 解除 —— 此前 colDef.unique = false
// 静默解除约束(重启后 persistSchemas 使约束永久消失)。对齐 SQLite 语义:
// 约束随建表存在,解除需重建表;仅 CREATE UNIQUE INDEX 添加的约束可随索引删除。
const uniqueKey = `${tableName}:${column}`;
if (colDef.unique && !this.uniqueIndexCols.has(uniqueKey)) {
throw new DatabaseError(
`Cannot drop index on column "${column}" in table "${tableName}": ` +
'UNIQUE constraint defined at table creation must be removed by recreating the table',
'NOT_SUPPORTED',
);
}
colDef.index = false;
colDef.unique = false;
this.uniqueIndexCols.delete(uniqueKey);
const idxKey = `${tableName}:idx:${column}`;
const idxLsm = this.secondaryIndexes.get(idxKey);
if (idxLsm) {
await idxLsm.clear();
this.secondaryIndexes.delete(idxKey);
}
await this.persistSchemas();
}
// =======================================================================
// 事务
// =======================================================================
async beginTransaction(): Promise<void> {
if (this.currentTxnId) throw new DatabaseError('Transaction already in progress', 'TX_ACTIVE');
this.currentTxnId = this.mvcc.beginTransaction();
this.txnSnapshot = new Map();
// v0.8.0: 事务内 WAL 记录计数从 0 开始(保存点边界依赖它)
this.txnWalRecordCount = 0;
// v0.7.3-fix: WAL BEGIN 写失败回滚内存事务状态 —— 此前 append 抛错(full 模式)
// 时 currentTxnId 已设置 → TX_ACTIVE 永久泄漏(后续无法开始新事务)。
// 回滚 mvcc 登记 + 快照后重抛,调用方可重试。
try {
await this.wal.append({
type: WALRecordType.BEGIN,
txnId: this.currentTxnId,
tableName: '',
key: '',
});
this.txnWalRecordCount = 1; // BEGIN 本身占一条
} catch (error) {
this.mvcc.rollbackTransaction(this.currentTxnId);
this.currentTxnId = null;
this.txnSnapshot = null;
throw error;
}
}
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) {
this.lsm.delete(key);
} else {
this.lsm.put(key, value);
}
}
}
this.mvcc.commitTransaction(this.currentTxnId);
// v0.8.0 根治:事务结束时清空 savepoint 表。
// 此前 commit/rollback **都不清理** savepoints,于是:
// 1. 上一个事务的名字仍被占用 → 新事务 SAVEPOINT 同名报 "already exists"
// 2. 新事务 ROLLBACK TO 陈旧名字会拿到旧事务快照,把当前事务的写入静默替换。
this.savepoints.clear();
this.savepointWalBoundary.clear();
this.txnWalRecordCount = 0;
this.currentTxnId = null;
this.txnSnapshot = null;
}
async rollbackTransaction(): Promise<void> {
if (!this.currentTxnId) throw new DatabaseError('No active transaction', 'TX_NONE');
const txnId = this.currentTxnId;
// v0.7.3-fix: 先持久化 WAL ROLLBACK,再回滚内存 —— 与 commitTransaction 的
// "WAL 领先内存"v0.4.3-fix)对齐。此前内存先回滚、ROLLBACK 记录后写:
// full 模式写失败时崩溃重放无 ROLLBACK 记录 → 已回滚事务的数据复活。
// 现在写失败 → 内存未回滚、事务仍活跃(调用方可重试),崩溃后重放
// 看到 ROLLBACK 记录同样不会复活数据。
await this.wal.append({
type: WALRecordType.ROLLBACK,
txnId,
tableName: '',
key: '',
});
// v0.3.3: 记录事务涉及的表(用于回滚后重建索引,消除索引残留)
const affectedTables = new Set<string>();
if (this.txnSnapshot) {
for (const key of this.txnSnapshot.keys()) {
const idx = key.indexOf(':');
if (idx > 0) affectedTables.add(key.slice(0, idx));
}
}
this.mvcc.rollbackTransaction(txnId);
this.txnSnapshot = null;
// v0.8.0:事务结束清空 savepoint(理由同 commitTransaction
this.savepoints.clear();
this.savepointWalBoundary.clear();
this.txnWalRecordCount = 0;
this.currentTxnId = null;
// v0.3.3: 事务内直接写入了二级索引 LSM,回滚后全量重建受影响表的索引
for (const tableName of affectedTables) {
if (this.schemas.has(tableName)) {
await this.reindexTable(tableName);
}
}
}
// ---- Savepoint 嵌套事务 ----
private savepoints: Map<string, { txnId: number; snapshot: Map<string, Record<string, unknown>> | null }> = new Map();
/**
* v0.8.0: 当前事务在 WAL 中已成功追加的记录条数。
*
* 用途:保存点需要记录"回滚后应保留到哪一条",否则恢复时无法区分
* "保存点之前的写入"(应保留)与"保存点之后的写入"(应丢弃)。
*/
private txnWalRecordCount = 0;
/** 保存点 → 该保存点时事务的 WAL 记录边界 */
private savepointWalBoundary = new Map<string, number>();
async savepoint(name: string): Promise<void> {
if (!this.currentTxnId) throw new DatabaseError('No active transaction for savepoint', 'TX_NONE');
if (this.savepoints.has(name)) throw new DatabaseError(`Savepoint "${name}" already exists`, 'SAVEPOINT_EXISTS');
// 保存当前事务快照
this.savepoints.set(name, {
txnId: this.currentTxnId,
snapshot: this.txnSnapshot ? new Map(this.txnSnapshot) : null,
});
// v0.8.0: 记录边界(该事务已追加的记录条数)—— 见 txnWalRecordCount 说明
this.savepointWalBoundary.set(`${this.currentTxnId}:${name}`, this.txnWalRecordCount);
}
async rollbackToSavepoint(name: string): Promise<void> {
const sp = this.savepoints.get(name);
if (!sp) throw new DatabaseError(`Savepoint "${name}" not found`, 'SAVEPOINT_NOT_FOUND');
// v0.8.0 根治:savepoint 归属校验。
// 此前只按名字查找,而 savepoints 在 commit/rollback 时**从不清空** ——
// 上一个事务遗留的 savepoint 会让当前事务的写入被旧事务快照静默替换
// (实测:本事务 update 后 ROLLBACK TO 陈旧名 + COMMIT,写入凭空消失)。
if (sp.txnId !== this.currentTxnId) {
throw new DatabaseError(
`Savepoint "${name}" belongs to a different transaction (stale savepoint)`,
'SAVEPOINT_NOT_FOUND',
);
}
// v0.8.0: 先写 WAL 标记再改内存(与 commit/rollback 的"WAL 领先内存"一致)——
// 否则崩溃恢复会把该事务在 savepoint 之前写入的记录重新应用,已回滚的行复活。
const boundary = this.savepointWalBoundary.get(`${this.currentTxnId}:${name}`) ?? 0;
await this.wal.append({
type: WALRecordType.SAVEPOINT_ROLLBACK,
txnId: this.currentTxnId!,
tableName: '',
key: '',
data: { replayFromIndex: boundary },
});
this.txnWalRecordCount++;
this.savepointWalBoundary.set(`${this.currentTxnId}:${name}`, boundary);
// v0.6.3-fix: 记录当前快照涉及的表(回滚后重建索引)—— 此前事务内直写
// 索引 LSMsavepoint 回滚只还原快照 → savepoint 之后的索引条目残留
const affectedTables = new Set<string>();
if (this.txnSnapshot) {
for (const key of this.txnSnapshot.keys()) {
const idx = key.indexOf(':');
if (idx > 0) affectedTables.add(key.slice(0, idx));
}
}
// 恢复到 savepoint 时的快照
this.txnSnapshot = sp.snapshot ? new Map(sp.snapshot) : null;
// v0.3.3: 清理该事务在 MVCC 版本链中的全部记录(快照已含正确数据,
// 版本链仅作 undo 记录,清空后 commit 时 LSM 写入与快照保持一致)
this.mvcc.discardVersions(this.currentTxnId!);
// 清除此 savepoint 之后的所有 savepoint
let found = false;
for (const [k] of this.savepoints) {
if (k === name) { found = true; continue; }
if (found) this.savepoints.delete(k);
}
// v0.6.3-fix: 重建受影响表二级索引(消除 savepoint 之后的过期索引条目)
for (const tableName of affectedTables) {
if (this.schemas.has(tableName)) {
await this.reindexTable(tableName);
}
}
}
async releaseSavepoint(name: string): Promise<void> {
if (!this.savepoints.has(name)) throw new DatabaseError(`Savepoint "${name}" not found`, 'SAVEPOINT_NOT_FOUND');
this.savepoints.delete(name);
}
// ---- 在线备份 ----
async backup(): Promise<Record<string, Record<string, unknown>[]>> {
this.ensureOpen();
const result: Record<string, Record<string, unknown>[]> = {};
for (const tableName of this.schemas.keys()) {
result[tableName] = await this.getAllRows(tableName);
}
return result;
}
// =======================================================================
// 内部
// =======================================================================
private async getAllRows(tableName: string): Promise<Record<string, unknown>[]> {
const pkCol = this.tablePKs.get(tableName)!;
const prefix = `${tableName}:`;
// 预加载范围内涉及的 SSTable,避免 rangeScan 时缓存未命中静默丢数据
const entries = await this.lsm.rangeScan(prefix, `${prefix}\uffff`);
const rows = entries.map(([key, value]) => {
// v0.8.0: 深拷贝(此前 `{ ...value }` 只做浅拷贝,嵌套 json 值仍与 LSM
// 内部对象共享引用 —— 调用方改 rows[0].nested.a 会改写存储)
const row = cloneRow(value);
row[pkCol] = key.slice(prefix.length);
return row;
});
// v0.3.3: 事务内合并未提交快照(update/delete/count/clear 也能看到本事务的写入)
return this.mergeTxnSnapshot(tableName, rows);
}
/**
* v0.3.3: 将事务未提交快照的变更合并到行列表(新增/更新/删除标记)。
* 幂等操作:行已是最新时不重复修改。
*/
private mergeTxnSnapshot(tableName: string, rows: Record<string, unknown>[]): Record<string, unknown>[] {
if (!this.currentTxnId || !this.txnSnapshot) return rows;
const pkCol = this.tablePKs.get(tableName)!;
const prefix = `${tableName}:`;
for (const [key, value] of this.txnSnapshot) {
if (!key.startsWith(prefix)) continue;
const pk = key.slice(prefix.length);
const del = (value as unknown as Record<string, unknown>).__txn_deleted;
const idx = rows.findIndex((r) => r[pkCol] === pk);
if (del) {
if (idx >= 0) rows.splice(idx, 1);
} else {
const row = { ...value, [pkCol]: pk };
if (idx >= 0) rows[idx] = row;
else rows.push(row);
}
}
return rows;
}
private getPK(schema: TableSchema): string {
for (const [name, col] of Object.entries(schema.columns)) {
if (col.primaryKey) return name;
}
return Object.keys(schema.columns)[0];
}
private validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
// v0.8.0B-1):委托给**唯一**的行校验实现(table/validation.ts)。
//
// 此前这里是第二份独立实现(经由 checkFieldType 恰好覆盖了 maxLength/min/max
// 而 Memory 那份没有)—— 于是同一份 schema、同一条 INSERT 是否报约束错误
// 取决于选了哪个引擎(缺陷 A12),且两者对未知列都静默丢弃(A17)。
return compileValidator(schema).validateRow(row);
}
/**
* v0.8.0(B-1):写入前置校验 —— 见 `IStorageEngine.validatePayload` 契约。
*/
async validatePayload(
tableName: string,
rows: Record<string, unknown>[],
mode: 'insert' | 'update' = 'insert',
): Promise<void> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const validator = compileValidator(schema);
for (const row of rows) {
if (mode === 'update') validator.validatePartial(stripUndefinedUpdates(row));
else validator.validateRow(row);
}
}
// =======================================================================
// Schema 持久化
// =======================================================================
/**
* v0.8.0B-6):表结构**随 manifest 一起提交**(单一提交点)。
*
* 修复前 DDL 结束时会单独写一份 `__aria_schemas`:结构变更与存储状态
* SSTable meta / 页面 / WAL 水位)各自落盘,中间崩溃就会留下"结构说加过列、
* 数据里没有"或反之的分裂状态。现在两者在同一次原子提交里生效。
*/
private async persistSchemas(): Promise<void> {
await this.commitManifest();
}
/**
* v0.8.0review 修复):表结构记录的**唯一**解析实现。
*
* 为什么必须集中且严格:结构记录有两种坏法 —— JSON 本身就坏了,或 JSON 合法但
* 形状不对(数组 / null / 表名映射到非对象 / 列定义不是对象)。修复前只有
* "JSON 坏"这一种会抛错,形状不对则被**静默忽略** → 打开后看不到任何表,
* 表现为"库是空的"(与审计里"静默空库"同一类缺陷)。
*
* 判定原则:只要记录存在却不可用,就抛 ARIA_LEGACY_META_CORRUPT —— 宁可让
* 调用方看到明确的损坏错误,也不假装这是一个没有表的空库。
*/
private parseSchemaRecord(raw: ArrayBuffer, source: string): Record<string, Record<string, ColumnDef>> {
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder().decode(raw));
} catch (error) {
throw new DatabaseError(
`${source} is corrupt and cannot be loaded: ${(error as Error).message}`,
'ARIA_LEGACY_META_CORRUPT',
error,
);
}
return this.validateSchemaShape(parsed, source);
}
/** 形状校验(与 parseSchemaRecord 分离:便于直接喂各种形状做单测) */
private validateSchemaShape(parsed: unknown, source: string): Record<string, Record<string, ColumnDef>> {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new DatabaseError(
`${source} is not a table-name → columns object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`,
'ARIA_LEGACY_META_CORRUPT',
);
}
const out: Record<string, Record<string, ColumnDef>> = {};
for (const [tableName, columns] of Object.entries(parsed as Record<string, unknown>)) {
if (!columns || typeof columns !== 'object' || Array.isArray(columns)) {
throw new DatabaseError(
`${source} entry "${tableName}" is not a column map (got ${Array.isArray(columns) ? 'array' : typeof columns})`,
'ARIA_LEGACY_META_CORRUPT',
);
}
for (const [colName, def] of Object.entries(columns as Record<string, unknown>)) {
if (!def || typeof def !== 'object' || Array.isArray(def)) {
throw new DatabaseError(
`${source} entry "${tableName}.${colName}" is not a column definition ` +
`(got ${Array.isArray(def) ? 'array' : typeof def})`,
'ARIA_LEGACY_META_CORRUPT',
);
}
}
out[tableName] = columns as Record<string, ColumnDef>;
}
return out;
}
private async loadSchemas(): Promise<void> {
// 权威来源:manifest(旧格式已在 importLegacyState 阶段导入)
let data = this.manifest.schemas ?? {};
// 兼容回退:manifest 中没有任何表结构,但旧格式记录存在
//(例如上一次是被旧版本打开的库)→ 读进来并纳入 manifest
if (Object.keys(data).length === 0) {
const raw = await this.backend.read('__aria_schemas');
if (raw) {
// 修复前这里 `catch {}` 静默忽略 → 坏 schema = 看不到任何表(静默空库)
data = this.parseSchemaRecord(raw, 'Schema record "__aria_schemas"');
this.manifest.schemas = data;
}
}
for (const [tableName, columns] of Object.entries(data)) {
const schema: TableSchema = { name: tableName, columns };
this.schemas.set(tableName, schema);
this.tablePKs.set(tableName, this.getPK(schema));
}
}
// =======================================================================
// v0.8.0(B-6):单一提交点与命名空间工厂
// =======================================================================
/**
* v0.8.0review 修复): 本次打开/修复过程中出现过的**一切损坏迹象**。
*
* 为什么需要它而不是只看 `this.recoveryReport.dataLossSuspected`
* SSTable 被丢弃这一事实记录在**各 LSM** 的报告里,引擎层的 `dataLossSuspected`
* 只在"WAL 水位已推进、被丢的数据没有 WAL 兜底"时才置位。于是"manifest 已被
* 推进 + 某个 SSTable 因文件损坏被丢"这类**真损坏**会在引擎层看不到 —— 孤儿页
* 回收就会照常执行,把在途/退休文件按"没人引用"删掉。
*
* 判定原则:只要有任何"曾经自愈/丢失/回退"的迹象,就一律不回收任何未被引用
* 的文件(宁可留空间,也不可逆地删数据)。
*/
private describeRecoveryDamage(): string[] {
const reasons: string[] = [];
if (this.recoveryReport.manifestFallback) reasons.push('manifest fallback to previous generation');
if (this.recoveryReport.dataLossSuspected) reasons.push('engine-level data loss suspected');
if (this.recoveryReport.walGaps.length > 0) {
reasons.push(`WAL segment gap(s) ${this.recoveryReport.walGaps.join(',')}`);
}
if (this.recoveryReport.droppedWALRecords > 0) {
reasons.push(`${this.recoveryReport.droppedWALRecords} corrupt WAL record(s)`);
}
for (const lsm of this.allLsms()) {
const r = lsm.getRecoveryReport();
if (r.droppedSSTables.length > 0) {
reasons.push(`${r.namespace}: ${r.droppedSSTables.length} dropped SSTable(s)`);
}
if (r.dataLossSuspected) reasons.push(`${r.namespace}: LSM data loss suspected`);
}
return reasons;
}
/**
* v0.8.0: 恢复诊断(打开时被丢弃的 SSTable、WAL 空洞、是否怀疑数据丢失)。
*
* 数据来自两处:引擎层(WAL 空洞 / 迁移 / manifest 回退)与各 LSM(被丢弃的
* SSTable + 其 `dataLossSuspected`),这里合并成一份对外的报告 —— 于是
* "这次打开到底自愈了什么、有没有真丢数据"是**可读的返回值**而不是只能翻日志。
*/
getRecoveryReport(): AriaRecoveryReport {
const dropped = this.recoveryReport.droppedSSTables.map((d) => ({ ...d }));
let dataLoss = this.recoveryReport.dataLossSuspected;
for (const lsm of this.allLsms()) {
const r = lsm.getRecoveryReport();
for (const d of r.droppedSSTables) {
dropped.push({ namespace: r.namespace, ...d });
}
if (r.dataLossSuspected) dataLoss = true;
}
return {
droppedSSTables: dropped,
dataLossSuspected: dataLoss,
walGaps: [...this.recoveryReport.walGaps],
droppedWALRecords: this.recoveryReport.droppedWALRecords,
legacyImported: this.recoveryReport.legacyImported,
manifestFallback: this.recoveryReport.manifestFallback,
};
}
/**
* v0.8.0: LSM 的唯一构造点。
*
* 为什么集中:本项目最反复的缺陷模式就是"同一语义在多处实现、只修一处"
* (审计结论的原话)。主 LSM 与二级索引 LSM 的配置必须完全一致地带上
* 命名空间、WAL LSN 提供者与 durable-coverage 语义,因此只能有一个工厂。
*/
private createLSM(ns: string): LSM {
return new LSM({
memtableSizeThreshold: this.config.memtableSizeThreshold,
levelSizeMultiplier: this.config.levelSizeMultiplier,
blockSize: this.config.pageSize,
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
// SSTable 缓存上限 = BufferPool 页数 × 页面大小(默认 256 页 ≈ 1MB 可控内存)
cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize,
sstableStore: this.createSSTableStore(ns),
namespace: ns,
// 冻结时刻的 WAL LSN → manifest 的冻结表意图(WAL 水位下限)
walLsnProvider: () => this.wal?.getLsn() ?? 0,
// WAL 已被截断(startLsn > 0)时,SSTable 缺失/损坏 = 已确认数据真的丢了
requireDurableCoverage: (this.manifest?.wal.startLsn ?? 0) > 0,
});
}
/** 全部 LSM(主 + 二级索引) */
private allLsms(): LSM[] {
return [this.lsm, ...this.secondaryIndexes.values()];
}
/**
* v0.8.0: 把全部 LSM 的数据落盘。
* @param memtablesOnly true = 只落 memtable,不等 compactioncheckpoint 用)
*/
private async flushAllLsms(memtablesOnly: boolean = false): Promise<void> {
for (const lsm of this.allLsms()) {
if (memtablesOnly) await lsm.flushMemtablesOnly();
else await lsm.flush();
}
}
/** v0.8.0: 是否存在任何未落盘的 LSM 数据(决定 WAL 水位能否推进) */
private hasPendingFlushData(): boolean {
return this.allLsms().some((lsm) => lsm.hasPendingFlushData());
}
/** schema 的持久化形态(与旧 `__aria_schemas` 同形) */
private serializeSchemas(): Record<string, Record<string, ColumnDef>> {
const data: Record<string, Record<string, ColumnDef>> = {};
for (const [name, schema] of this.schemas) {
data[name] = schema.columns;
}
return data;
}
/** 所有 LSM 的待落盘冻结表意图(manifest 记录,阻止 WAL 水位越过它们) */
private collectFrozenIntents(): ManifestFrozenIntent[] {
const intents: ManifestFrozenIntent[] = [];
for (const lsm of this.allLsms()) {
intents.push(...lsm.getFrozenIntents());
}
return intents;
}
/**
* v0.8.0B-6):**唯一的 manifest 提交入口**。
*
* 每次提交都重新计算权威字段,因此并发/交错场景下"最后一次提交"总是包含
* 完整的最新状态:
* - `pageIdWatermark`:单调推进、永不复用;
* - `schemas`:表结构的权威描述(DDL 不再依赖独立的 `__aria_schemas` 提交);
* - `frozen`:待落盘冻结表意图;
* - `wal.startLsn`**只有全部数据已落盘时才推进**(否则保持原值),
* 这是"截断 WAL 前必须先提交 manifest"的可验证形式。
*/
private async commitManifest(targetDurableLsn?: number): Promise<void> {
if (!this.opened && !this.manifestStore) return;
const intents = this.collectFrozenIntents();
this.manifest.pageIdWatermark = Math.max(
this.manifest.pageIdWatermark,
this.fileManager.getNextPageId(),
);
this.manifest.schemas = this.serializeSchemas();
this.manifest.frozen = intents;
const computed = this.computeDurableLsn(intents);
// 允许调用方给一个**更保守**的目标水位(如"先算分片边界、再提交"的清理路径)。
// 只取更小者:水位永远不允许超过"当前真实可保证"的值。
this.durableLsn = typeof targetDurableLsn === 'number'
? Math.min(computed, Math.max(this.durableLsn, targetDurableLsn))
: computed;
this.manifest.wal = {
startSegment: this.walStartSegment,
startLsn: this.durableLsn,
nextLsn: Math.max(this.manifest.wal.nextLsn, this.wal.getLsn()),
};
this.manifest = await this.manifestStore.commit();
}
/**
* v0.8.0:计算"当前可以保证的落盘水位"(纯函数,不改状态)。
*
* 三条规则:
* - 有冻结表意图 → 水位不得越过最早的冻结表内容起点(它们的记录只在内存+WAL);
* - 有未落盘 memtable → 维持原水位;
* - 全部落盘 → 推进到当前 LSN(这些记录已存在于已提交的 SSTable 中)。
*/
private computeDurableLsn(intents: ManifestFrozenIntent[]): number {
// v0.8.0review 修复 P0):**事务进行中一律不得推进水位**。
//
// 事务内的写入只落在 `txnSnapshot`(内存)+ WAL 里,**不进 LSM memtable**
// —— 因此 `hasPendingFlushData()`(只看 memtable/frozen)会说"没有未落盘数据",
// 水位就被推到当前 LSN 并按该水位删掉旧分片。而此时事务记录既不在 SSTable
// 也不在 memtable:随后 `COMMIT`(返回成功)→ 崩溃 → 重开时那些 INSERT 记录
// 因 `lsn <= startLsn` 被跳过、只剩 COMMIT 记录 → **已确认提交的事务整批消失**,
// 且恢复报告是"干净"的(实测复现)。
//
// 水位是"这些 LSN 已存在于已提交 SSTable 中"的断言,而活跃事务的数据不满足它。
if (this.currentTxnId !== null) return this.durableLsn;
if (intents.length > 0) {
return Math.min(this.durableLsn, Math.min(...intents.map((i) => i.lsnAtFreeze)));
}
if (this.hasPendingFlushData()) return this.durableLsn;
return Math.max(this.durableLsn, this.wal.getLsn());
}
/**
* v0.8.0B-6):WAL 检查点的**唯一实现** —— "先算边界 → 提交 manifest → 再删除"。
*
* 顺序不可交换(见 `SegmentedWALStore.planKeepFrom` 的说明)。所有需要回收 WAL
* 空间的路径(打开恢复后、close、repair、周期 checkpoint)都必须走这里,
* 否则又会出现"同一语义多处实现、只改一处"的老问题。
*/
private async advanceWalCheckpoint(): Promise<void> {
// v0.8.0review 修复 P0):事务活跃时整条水位推进 + 分片回收都不做。
// 与 `CheckpointManager` 的两个回调同一守卫;这里放在入口处,
// 使 repair()/close()/周期 checkpoint 三条路径全部覆盖(它们此前只有后两条有守卫)。
if (this.currentTxnId !== null) {
// eslint-disable-next-line no-console
console.warn(
'[AriaEngine] WAL checkpoint deferred: an active transaction may hold data ' +
'that exists only in memory + WAL (advancing the durable watermark would drop it)',
);
return;
}
await this.wal.flush();
// 1. 先算:以"如果没有未落盘数据,水位会到哪里"为基准
const target = this.hasPendingFlushData()
? this.durableLsn
: Math.max(this.durableLsn, this.wal.getLsn());
// 2. 再提交(manifest 记录 startSegment + startLsn
// 注意:先算边界是为了让 manifest 里的 startSegment **不小于**介质上真实存在的
// 分片号 —— 否则恢复时无法区分"正常清理过的前缀"与"介质丢了一段记录"。
this.walStartSegment = await this.wal.planKeepFromSegment(target);
await this.commitManifest(target);
// 3. 最后才允许删除分片
await this.wal.checkpointBefore(target);
}
/**
* v0.8.0:旧格式(v0.8.0 之前)状态导入。
*
* 导入必须是**全有或全无**的:
* - 旧 meta 存在但无法解析 → 抛错(`ARIA_LEGACY_META_CORRUPT`)。
* 修复前 `readMetaList()` 遇到坏 JSON 返回 `[]`,于是"元数据损坏"直接
* 表现为"空库",随后 repair 还会把没人引用的活页全部删掉(不可逆)。
*/
private async importLegacyState(): Promise<void> {
const keys = await this.backend.listKeys();
let imported = false;
// 1. 各命名空间 SSTable meta__aria_lsm_meta / __aria_lsm_meta_<ns>
const metaKeys = keys.filter((k) => k === '__aria_lsm_meta' || k.startsWith('__aria_lsm_meta_'));
for (const key of metaKeys) {
const ns = key === '__aria_lsm_meta' ? 'main' : key.slice('__aria_lsm_meta_'.length);
const raw = await this.backend.read(key);
if (!raw) continue;
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder().decode(raw));
} catch (error) {
throw new DatabaseError(
`Legacy SSTable metadata "${key}" is corrupt and cannot be migrated: ${(error as Error).message}`,
'ARIA_LEGACY_META_CORRUPT',
error,
);
}
if (!Array.isArray(parsed)) {
throw new DatabaseError(
`Legacy SSTable metadata "${key}" is not an array`,
'ARIA_LEGACY_META_CORRUPT',
);
}
const sstables: SSTableMeta[] = [];
for (const item of parsed) {
const m = item as Partial<SSTableMeta>;
if (typeof m?.id !== 'number' || typeof m?.level !== 'number'
|| typeof m?.minKey !== 'string' || typeof m?.maxKey !== 'string') {
throw new DatabaseError(
`Legacy SSTable metadata "${key}" has an entry with unexpected shape: ${JSON.stringify(item)}`,
'ARIA_LEGACY_META_CORRUPT',
);
}
sstables.push({
id: m.id,
level: m.level,
minKey: m.minKey,
maxKey: m.maxKey,
blockCount: typeof m.blockCount === 'number' ? m.blockCount : 0,
totalSize: typeof m.totalSize === 'number' ? m.totalSize : 0,
bloomData: null,
...(Array.isArray(m.pageIds) ? { pageIds: [...m.pageIds] } : {}),
});
}
sstables.sort((a, b) => b.id - a.id);
this.manifest.namespaces[ns] = {
nextSstableId: sstables.reduce((max, m) => Math.max(max, m.id), 0),
sstables,
};
imported = true;
}
// 2. 表结构(__aria_schemas
const schemaRaw = await this.backend.read('__aria_schemas');
if (schemaRaw) {
// 与 loadSchemas 走**同一个**校验实现(形状不对同样抛错,绝不静默空库)
this.manifest.schemas = this.parseSchemaRecord(schemaRaw, 'Legacy schema record "__aria_schemas"');
imported = true;
}
// 3. 页面水位(__aria_meta,仅作为单调下限)
const pageMeta = await this.backend.read('__aria_meta');
if (pageMeta && pageMeta instanceof ArrayBuffer && pageMeta.byteLength >= 4) {
const watermark = new DataView(pageMeta).getUint32(0, false);
if (watermark > this.manifest.pageIdWatermark) {
this.manifest.pageIdWatermark = watermark;
imported = true;
}
}
if (imported) {
this.recoveryReport.legacyImported = true;
// eslint-disable-next-line no-console
console.warn(
'[AriaEngine] migrated legacy storage layout into __aria_manifest ' +
'(old keys are kept untouched as a fallback)',
);
}
}
/**
* v0.8.0:恢复后校验冻结表意图。
*
* 语义:manifest 记录了"某张冻结表还没落盘"(意图),说明它的数据要么在 WAL 里,
* 要么已经在 SSTable 里。若本次打开**一条 WAL 记录都没有重放到**,而 manifest
* 又声称有未落盘数据,那么这些"已确认写入"就是真的丢了(WAL 被截断/介质丢失)。
* 此时抛错 —— 修复前这种丢失完全不可观测。
*/
private verifyFrozenIntentsAfterRecovery(replayedRecordCount: number): void {
const intents = this.manifest.frozen;
if (intents.length === 0) return;
if (replayedRecordCount > 0) return; // WAL 覆盖到了这些数据(重放会重建)
if (!this.config.walEnabled) return; // 未启用 WAL:本来就没有日志兜底(配置语义)
const summary = intents.map((i) => `${i.ns}#${i.id}(${i.entryCount} 项)`).join(', ');
throw new DatabaseError(
`AriaEngine manifest declares ${intents.length} un-flushed frozen table(s) [${summary}] ` +
'but no WAL record was replayed — confirmed writes are missing ' +
'(WAL truncated or lost, and the data is not in any committed SSTable)',
'ARIA_WRITE_LOST',
);
}
/**
* 创建命名空间隔离的 SSTableStore**manifest 权威**)。
*
* 主 LSM 与每个二级索引 LSM 各持有独立实例:
* - 文件 key 前缀隔离(sst_ / sst_idx_${table}_${col}_
* - 元数据在 manifest 的 `namespaces[ns]` 中隔离(不再各写一份裸 JSON)
* - id 序列独立且**单调推进**`nextSstableId` 记在 manifest 里,
* 即便某一代 SSTable 全部被删除,id 也不会被复用)
*
* v0.8.0B-6)两处结构变化:
* 1. **meta 不再走裸 JSON**`saveMeta`/`deleteMeta` 直接改 manifest 并提交。
* 修复前 `JSON.parse` 失败 → 返回 `[]` → 元数据损坏 = 静默空库
* (随后 repair 还会把没人引用的活页删掉);
* 2. `load/delete` 的页面映射改由 PageSSTableStore 自己维护
* (活跃 + 退休两张表)—— 于是"compaction 摘除 meta"与
* "在途读者按 id 读取"不再互相矛盾。
*/
private createSSTableStore(ns: string): SSTableStore {
const filePrefix = ns === 'main' ? 'sst_' : `sst_${ns}_`;
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
const usePages = this.isPageStorage();
// v0.8.0A38):compression 必须传给 pageStore —— 页面化是默认路径,
// 不传就等于"默认配置下 compression 被静默忽略"(修复前的实际状态)。
const pageStore = usePages
? new PageSSTableStore(this.fileManager, this.bufferPool, this.config.compression)
: null;
// 打开时把 manifest 中已有的页面映射注册进 pageStore,
// 使 load(id) 不再依赖"调用方传 pageIds"(退休 SSTable 也要能读)
if (pageStore) {
const existing = this.manifest.namespaces[ns]?.sstables ?? [];
for (const meta of existing) {
if (meta.pageIds && meta.pageIds.length > 0) {
pageStore.registerPageIds(meta.id, meta.pageIds, meta.totalSize);
}
}
}
const nsState = (): { nextSstableId: number; sstables: SSTableMeta[] } => {
let state = this.manifest.namespaces[ns];
if (!state) {
state = { nextSstableId: 0, sstables: [] };
this.manifest.namespaces[ns] = state;
}
return state;
};
return {
save: async (id, data) => {
if (pageStore) {
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
// 压缩由 pageStore 内部完成(整体压缩后再切页,压缩率优于逐页)
return pageStore.save(id, data);
}
let buf: ArrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5
if (this.config.compression) {
const compressed = compressLZ4(new Uint8Array(buf));
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength) as ArrayBuffer;
}
await this.backend.write(`${filePrefix}${id}`, buf);
// 整 value 路径:落盘长度即压缩后长度(与页面化路径语义一致)
return { storedSize: buf.byteLength };
},
load: async (id) => {
// 页面化读取:pageStore 自己维护 id → pageIds(含退休表)
if (pageStore) {
const paged = await pageStore.load(id);
if (paged !== null) return paged;
}
// 旧数据(页面化之前写入的整 value)或页面缺失 → 回退整 value 读取
const raw = await this.backend.read(`${filePrefix}${id}`);
if (!raw) return null;
let buf = new Uint8Array(raw);
// 解压(若启用)— 解密由 EncryptedBackend 在 backend 层透明处理(v0.4.5
if (this.config.compression && !pageStore) {
// v0.4.5: 压缩流自带原始大小头,无需外部估算
buf = decompressLZ4(buf) as Uint8Array<ArrayBuffer>;
}
return buf;
},
delete: async (id) => {
if (pageStore) await pageStore.delete(id);
await this.backend.delete(`${filePrefix}${id}`);
},
allocateId: async () => {
// 单调推进、永不复用:id 序列记在 manifest 里(不依赖"现存最大 id"
const state = nsState();
const maxExisting = state.sstables.reduce((max, m) => Math.max(max, m.id), 0);
state.nextSstableId = Math.max(state.nextSstableId, maxExisting) + 1;
return state.nextSstableId;
},
listMeta: async () => nsState().sstables.map((m) => ({ ...m })),
saveMeta: async (meta) => {
const state = nsState();
// v0.4.5: 页面化时把页面 ID 列表注入 metasave 后、saveMeta 前由 LSM 顺序调用)
const pageIds = pageStore?.getPageIds(meta.id);
const metaWithPages: SSTableMeta = pageIds && pageIds.length > 0
? { ...meta, pageIds: [...pageIds] }
: { ...meta, bloomData: null };
const idx = state.sstables.findIndex((m) => m.id === meta.id);
if (idx >= 0) state.sstables[idx] = metaWithPages;
else state.sstables.push(metaWithPages);
state.nextSstableId = Math.max(state.nextSstableId, meta.id);
// ★ 单一提交点:数据已落盘(save 返回)→ 现在提交元数据
await this.commitManifest();
},
deleteMeta: async (id) => {
const state = nsState();
state.sstables = state.sstables.filter((m) => m.id !== id);
// manifest 摘除后,页面映射转入"退休表":在途读者仍可按 id 读取
pageStore?.retirePageIds(id);
await this.commitManifest();
},
deleteManyMetas: async (ids) => {
if (ids.length === 0) return;
const state = nsState();
const removing = new Set(ids);
state.sstables = state.sstables.filter((m) => !removing.has(m.id));
for (const id of ids) pageStore?.retirePageIds(id);
await this.commitManifest();
},
retire: (ids) => {
for (const id of ids) pageStore?.retirePageIds(id);
},
};
}
/** v0.4.5: 是否启用页面化物理存储(默认 OPFS / KVStore 后端启用,显式配置可覆盖) */
private isPageStorage(): boolean {
if (this.config.pageStorage === true) return true;
if (this.config.pageStorage === false) return false;
// v0.6.1: kv 后端同样页面化 — 整 value SSTable4MB)超过 1MB 页面缓存时
// 每次读取全量重载;拆 4KB 页面后缓存按页命中,大数据量读放大消除
return this.config.storageBackend === 'opfs' || this.config.storageBackend === 'kv';
}
// =======================================================================
// WAL 恢复
// =======================================================================
private applyWALRecord(record: WALRecord): void {
switch (record.type) {
case WALRecordType.INSERT:
case WALRecordType.UPDATE:
if (record.data) {
this.lsm.put(`${record.tableName}:${record.key}`, record.data);
}
break;
case WALRecordType.DELETE:
this.lsm.delete(`${record.tableName}:${record.key}`);
break;
case WALRecordType.CREATE_TABLE:
if (record.data?.schema) {
try {
const s = JSON.parse(record.data.schema as string) as TableSchema;
if (!this.schemas.has(s.name)) {
this.schemas.set(s.name, s);
this.tablePKs.set(s.name, this.getPK(s));
}
} catch { /* skip */ }
}
break;
case WALRecordType.ALTER_TABLE:
// v0.8.0A41):ALTER 是结构权威描述 → **覆盖**该表 schema(不是增量合并)。
// 幂等:重复回放同一记录结果相同。索引列的重建在恢复末尾由
// reindexTableInternal 统一完成(与 CREATE_TABLE 的处理一致)。
if (record.data?.schema) {
try {
const s = JSON.parse(record.data.schema as string) as TableSchema;
this.schemas.set(s.name, s);
this.tablePKs.set(s.name, this.getPK(s));
} catch { /* skip */ }
}
break;
case WALRecordType.COMMIT:
case WALRecordType.ROLLBACK:
case WALRecordType.BEGIN:
break;
}
}
/**
* v0.3.3: DROP_TABLE 恢复 — 删除 schema 并清除主 LSM 中该表的所有残留数据。
*
* 此前 DROP_TABLE 在恢复时被忽略,而 CREATE_TABLE 回放会重建 schema
* 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。
*/
private async applyDropTableRecovery(tableName: string): Promise<void> {
if (!tableName) return;
// v0.4.2-fix: 清理该表二级索引(崩溃恢复路径同样不留孤儿索引)
await this.cleanupTableIndexes(tableName);
this.schemas.delete(tableName);
this.tablePKs.delete(tableName);
// 清除主 LSM 中该表前缀的所有数据(含 SSTable 中的旧数据)
const prefix = `${tableName}:`;
const endKey = `${prefix}\uffff`;
const entries = await this.lsm.rangeScan(prefix, endKey);
for (const [key] of entries) {
this.lsm.delete(key);
}
}
// =======================================================================
// 二级索引
// =======================================================================
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
private uniqueColumns(tableName: string, schema: TableSchema): string[] {
const cols: string[] = [];
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique) continue;
if (this.secondaryIndexes.has(`${tableName}:idx:${colName}`)) cols.push(colName);
}
return cols;
}
/**
* 唯一性检查。
*
* v0.8.0: 由 `checkUniqueSync` 改名并改为 async —— 此前命名为 "Sync" 是因为它
* 依赖"批次级 prefetchPrefixRanges 之后索引数据已在缓存中"这一约定。现在
* LSM 读取自洽(未命中即回源),因此这里可以、也必须 await。
* 索引不含 null 条目(null 值不受唯一约束,与 MemoryEngine 语义一致)。
* @param currentPk 当前行主键(更新路径用于排除自身旧索引条目;插入路径无自身条目)
*/
private async checkUnique(
tableName: string,
uniqueCols: string[],
row: Record<string, unknown>,
currentPk: string,
): Promise<void> {
for (const colName of uniqueCols) {
const val = row[colName];
if (val === undefined || val === null) continue;
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
if (!idxLsm) continue;
const prefix = `${String(val)}:`;
const entries = await idxLsm.rangeScan(prefix, `${prefix}\uffff`);
for (const [, entry] of entries) {
const pk = (entry as unknown as { pk?: string }).pk;
if (pk !== undefined && pk !== currentPk) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${tableName}"`,
'UNIQUE_VIOLATION',
);
}
}
}
}
/** 更新行的二级索引条目 */
private updateSecondaryIndexes(
tableName: string, pkValue: string,
newRow: Record<string, unknown> | null,
oldRow: Record<string, unknown> | null,
): void {
const schema = this.schemas.get(tableName);
if (!schema) return;
for (const [colName, colDef] of Object.entries(schema.columns)) {
// v0.3.3: 主键列不建冗余二级索引(主 LSM 即 PK 索引)
if (!colDef.index && !colDef.unique) continue;
const idxKey = `${tableName}:idx:${colName}`;
const idxLsm = this.secondaryIndexes.get(idxKey);
if (!idxLsm) continue;
// 删除旧值
if (oldRow) {
const oldVal = oldRow[colName];
if (oldVal !== undefined && oldVal !== null) {
idxLsm.delete(`${String(oldVal)}:${pkValue}`);
}
}
// 插入新值
if (newRow) {
const newVal = newRow[colName];
if (newVal !== undefined && newVal !== null) {
idxLsm.put(`${String(newVal)}:${pkValue}`, { pk: pkValue });
}
}
}
}
/** 通过二级索引快速查找 */
private async tryIndexLookup(
tableName: string,
query: QueryPlan,
): Promise<Record<string, unknown>[] | null> {
if (!query.where) return null;
const schema = this.schemas.get(tableName);
if (!schema) return null;
const pkCol = this.tablePKs.get(tableName)!;
// v0.7.3: 递归展开 $and 中的等值条件 —— 此前仅顶层键,
// `WHERE a AND b`(解析为顶层 $and)永远全表扫描,索引形同虚设。
// $or/$not 语义不适用单索引下推,保守跳过。命中索引后 find 仍以
// 全条件 matchWhere 过滤(子集语义安全)。
const flat: [string, unknown][] = [];
const collect = (w: WhereCondition): void => {
for (const [k, v] of Object.entries(w)) {
if (k === '$and') {
for (const sub of (v as WhereCondition[])) collect(sub);
continue;
}
if (k === '$or' || k === '$not' || k === '$exists') continue;
flat.push([k, v]);
}
};
collect(query.where);
for (const [col, condition] of flat) {
const colDef = schema.columns[col];
const hasIndex = colDef && (colDef.index || colDef.unique || colDef.primaryKey);
if (!hasIndex && col !== pkCol) continue;
// PK 等值 → 主 LSM 精确查找
if (col === pkCol) {
if (typeof condition !== 'object' || condition === null) {
const key = `${tableName}:${condition}`;
const value = await this.lsm.get(key);
return value ? [{ ...value, [pkCol]: condition }] : [];
}
const cond = condition as Record<string, unknown>;
if ('$eq' in cond) {
const key = `${tableName}:${cond.$eq}`;
const value = await this.lsm.get(key);
return value ? [{ ...value, [pkCol]: cond.$eq }] : [];
}
// v0.3.3: PK $in → 主 LSM 多次精确查找(替代冗余 PK 二级索引)
if ('$in' in cond && Array.isArray(cond.$in)) {
const rows: Record<string, unknown>[] = [];
const seen = new Set<string>(); // v0.4.1: IN 子查询可能含重复值,按 pk 去重
for (const v of cond.$in) {
const pk = String(v);
if (seen.has(pk)) continue;
const value = await this.lsm.get(`${tableName}:${pk}`);
if (value) { seen.add(pk); rows.push({ ...value, [pkCol]: pk }); }
}
return rows;
}
// v0.3.3: PK 范围查询 → 主 LSM 前缀扫描 + 条件过滤(修复字符串算术 bug)
if ('$gt' in cond || '$gte' in cond || '$lt' in cond || '$lte' in cond) {
const prefix = `${tableName}:`;
const entries = await this.lsm.rangeScan(prefix, `${prefix}\uffff`);
const rows: Record<string, unknown>[] = [];
for (const [key, value] of entries) {
const candidate = { ...value, [pkCol]: key.slice(prefix.length) };
if (matchWhere(candidate, { [pkCol]: condition })) rows.push(candidate);
}
return rows;
}
}
// 二级索引查找
const idxKey = `${tableName}:idx:${col}`;
const idxLsm = this.secondaryIndexes.get(idxKey);
if (!idxLsm) continue;
// $eq → 精确查找
if (typeof condition !== 'object' || condition === null) {
// v0.6.2-fix(P1): IS NULL(条件为 null)不走索引 —— 索引不含 null 条目,
// String(null)="null" 查找返回空并短路全表 → 索引列 IS NULL 恒空
if (condition === null) continue;
return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition));
}
const c = condition as Record<string, unknown>;
if ('$eq' in c) {
// v0.6.2-fix(P1): 同上,$eq: nullIS NULL)不走索引
if (c.$eq === null) continue;
// v0.8.0 根治(与 MemoryEngine 同步):非原始值一律不走索引。
// `String({...})` 得到 "[object Object]" 这类无意义键,索引查找必然为空
// 并短路全表扫描 → 结果静默为空。真实触发场景是"未解析的操作数":
// { $eq: { $col: 'y' } } ← 列对列比较(t.x = t.y)
if (typeof c.$eq === 'object') continue;
const v = String(c.$eq);
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
}
// $in → 多次精确查找
if ('$in' in c && Array.isArray(c.$in)) {
// v0.6.2-fix(P1): IN 列表含 null 不走索引(索引不含 null 条目,会漏匹配 null 行)
if (c.$in.some((v) => v === null)) continue;
// v0.8.0: IN 列表含非原始值(未解析的 $subquery / $col)不走索引 ——
// String() 会得到无意义键,查找为空并短路全表扫描 → 静默空结果
if (c.$in.some((v) => typeof v === 'object' && v !== null)) continue;
// v0.7.3-perf: 批级预加载全部值的索引范围 + 主表行(各一次 drainChain)——
// 此前逐值 indexScanToRows:每个值一次 prefetchRange + prefetchKeys
// 后台 compaction 长耗时时 N 倍放大(与 v0.6.1 修的 insert 批量预加载
// 性能悬崖同类)。批级预加载后循环内同步 rangeScan/get。
const values = c.$in.map((v) => String(v));
const results: Record<string, unknown>[] = [];
const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
const pks: string[] = [];
for (const val of values) {
const entries = await idxLsm.rangeScan(val, `${val}\uffff`);
for (const [, idxEntry] of entries) {
const pk = (idxEntry as { pk?: string }).pk;
if (pk && !seenPks.has(pk)) {
seenPks.add(pk);
pks.push(pk);
}
}
}
for (const pk of pks) {
const row = await this.lsm.get(`${tableName}:${pk}`);
if (row) results.push({ ...row, [pkCol]: pk });
}
return results;
}
// $gt / $gte / $lt / $lte → 范围扫描
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
// v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值
// $gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
// 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),
// 边界语义与 where-matcher 完全一致。
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff');
return rows.filter((row) => matchWhere(row, { [col]: condition }));
}
}
return null;
}
/** 从索引扫描结果恢复完整行 */
private async indexScanToRows(
tableName: string, pkCol: string, idxLsm: LSM,
startKey: string, endKey: string,
): Promise<Record<string, unknown>[]> {
// 使用前缀扫描:endKey 需要包含 \uffff 以匹配所有带后缀的 key
const actualEndKey = endKey.includes('\uffff') ? endKey : `${endKey}\uffff`;
// 预加载索引 LSM 与主 LSM 涉及的 SSTable
const entries = await idxLsm.rangeScan(startKey, actualEndKey);
const pks: string[] = [];
for (const [, idxEntry] of entries) {
const pk = (idxEntry as any).pk as string;
if (pk) pks.push(pk);
}
const rows: Record<string, unknown>[] = [];
for (const pk of pks) {
const row = await this.lsm.get(`${tableName}:${pk}`);
if (row) rows.push({ ...row, [pkCol]: pk });
}
return rows;
}
// =======================================================================
// 辅助
// =======================================================================
/** 每 10 次 gc 计数器触发一次 MVCC 垃圾回收 */
private tryGC(): void {
this.gcCounter++;
if (this.gcCounter >= 10) {
this.mvcc.gc(100);
this.gcCounter = 0;
}
}
/** 回收主 LSM 与所有二级索引 LSM 的临时缓存超限 */
private trimAllCaches(): void {
this.lsm.trimCache();
for (const idxLsm of this.secondaryIndexes.values()) {
idxLsm.trimCache();
}
}
/** 检查内存预算,超出时强制 flush + GC */
private checkMemoryBudget(): void {
const maxBytes = this.config.maxMemoryMB * 1024 * 1024;
const used = this.lsm.getEstimatedMemory();
if (used > maxBytes) {
this.lsm.flush().catch(() => {});
this.mvcc.gc(50);
}
}
/**
* ANALYZE: 收集表统计信息
* 返回行数、平均行大小、索引深度等
*/
async analyzeTable(tableName: string): Promise<Record<string, unknown>> {
this.ensureOpen();
this.ensureTable(tableName);
const rows = await this.getAllRows(tableName);
// v0.7.3: 统计汇总主 LSM + 该表全部二级索引 LSM —— 此前只统计主 LSM,
// 表带多个索引时索引深度/SSTable 数量严重低估
let sstableCount = this.lsm.getStats().sstableCount;
let memtableSize = this.lsm.getStats().memtableSize;
let indexDepth = this.lsm.getStats().levelCounts.filter((c: number) => c > 0).length;
for (const [idxKey, idxLsm] of this.secondaryIndexes) {
if (!idxKey.startsWith(`${tableName}:idx:`)) continue;
const s = idxLsm.getStats();
sstableCount += s.sstableCount;
memtableSize += s.memtableSize;
indexDepth = Math.max(indexDepth, s.levelCounts.filter((c: number) => c > 0).length);
}
const stats: Record<string, unknown> = {
table: tableName,
rowCount: rows.length,
avgRowSize: rows.length > 0
? Math.round(rows.reduce((s, r) => s + JSON.stringify(r).length, 0) / rows.length)
: 0,
indexDepth,
sstableCount,
memtableSize,
estimatedMemory: this.lsm.getEstimatedMemory(),
};
// 列基数统计
const schema = this.schemas.get(tableName);
if (schema && rows.length > 0) {
const columnStats: Record<string, unknown> = {};
for (const colName of Object.keys(schema.columns)) {
const values = new Set(rows.map((r) => String(r[colName])));
columnStats[colName] = { distinctValues: values.size };
}
stats.columnStats = columnStats;
}
return stats;
}
/**
* REINDEX: 重建指定表的所有二级索引
*/
async reindexTable(tableName: string): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
return this.reindexTableInternal(tableName);
}
/** v0.4.2-fix: 重建索引内部实现(不校验 opened,供 open 恢复流程调用) */
private async reindexTableInternal(tableName: string): Promise<number> {
const schema = this.schemas.get(tableName);
if (!schema) return 0;
let rebuiltCount = 0;
// v0.7.4-perf: 单次全表扫描重建全部索引列 —— 此前每个索引列各做一次
// getAllRowsN 列 × M 行全表扫描 + 每次 prefetchRange drainChain),
// 多索引大表 REINDEX/崩溃恢复按索引列数线性放大
const pkCol = this.tablePKs.get(tableName)!;
const idxCols = Object.entries(schema.columns).filter(
([, colDef]) => colDef.index || colDef.unique,
);
if (idxCols.length === 0) return 0;
const rows = await this.getAllRows(tableName);
for (const [colName] of idxCols) {
const idxKey = `${tableName}:idx:${colName}`;
const idxLsm = this.secondaryIndexes.get(idxKey);
if (!idxLsm) continue;
// 清空旧索引
await idxLsm.clear();
rebuiltCount++;
// 从主 LSM 重建索引
for (const row of rows) {
const val = row[colName];
if (val !== undefined && val !== null) {
idxLsm.put(`${String(val)}:${row[pkCol]}`, { pk: row[pkCol] });
}
}
}
return rebuiltCount;
}
/**
* VACUUM: 压缩 LSM + 清理碎片
*/
async vacuum(): Promise<{ compactedLevels: number; gcVersions: number }> {
this.ensureOpen();
// 强制 flush memtablevacuum 的语义是"把可回收的空间收掉",必须先把内存数据落盘)
await this.lsm.flush();
// v0.8.0B-6):压缩**真实发生过的**层,并返回真实计数。
//
// 修复前:循环 `level < 6`(写死,MAX_LSM_LEVELS = 7**底部层永远不压缩**
// → 墓碑与历史版本在最底层永久累积),且无论是否真的合并过都返回
// `compactedLevels: 6`"报告的数字与事实无关",审计 item 52)。
// 现在逐层尝试(含底部层的原地合并 —— 它会回收墓碑),只统计真正合并了的层。
// 逐层压缩交给 LSM:它会把这些任务挂到**维护链**上串行执行 ——
// 直接 `await compactLevel()` 会与后台 compaction 并发写同一层的产物,
// 而产物一律 unshift 到队首(层内顺序 = 新旧顺序)→ 旧数据可能排到新数据
// 之前(读到旧值),底部层还会因丢墓碑让已删除的行复活。
const compactedLevels = await this.lsm.vacuumLevels();
// GC MVCC 版本(保留最新 10 个)
const beforeGC = this.mvcc.getGlobalLSN();
this.mvcc.gc(10);
return { compactedLevels, gcVersions: beforeGC };
}
private ensureOpen(): void {
if (!this.opened) throw new DatabaseError('AriaEngine not opened', 'DB_NOT_OPEN');
}
/** v0.4.2-fix: Aria 事务中 DDL 显式拒绝(结构变更无法通过行快照回滚) */
private ensureNoDDLInTransaction(op: string): void {
if (this.currentTxnId) {
throw new DatabaseError(
`${op} is not supported inside a transaction (AriaEngine DDL is not transactional)`,
'NOT_SUPPORTED',
);
}
}
private ensureTable(tableName: string): void {
if (!this.schemas.has(tableName)) {
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
}
}
}