方法:四个对抗性子代理分头审查(数据正确性 / 文档宣称 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 已重建。
3160 lines
138 KiB
TypeScript
3160 lines
138 KiB
TypeScript
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.0:CRC 校验失败被跳过的 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: 引擎内部错误统一包装为 DatabaseError(ARIA_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. WAL(LSN 从 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. 主 LSM(PK 索引)+ 二级索引 LSM
|
||
// ---------------------------------------------------------------------
|
||
this.lsm = this.createLSM('main');
|
||
|
||
// 5a. 恢复 Schema(manifest 权威;旧格式已在迁移阶段导入)
|
||
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.0(review 修复):记录级 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.0(B-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 主 LSM,checkpoint 截断 WAL 后崩溃时索引 memtable 未落盘、
|
||
// WAL 为空跳过重建 → 二级索引静默丢失最后一批条目(生产数据一致性问题)
|
||
await this.flushAllLsms();
|
||
},
|
||
// v0.8.0(B-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.0(B-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.0(B-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.0(review 修复):**有在途读者时一律不回收**。
|
||
// 读者的快照可能持有已被 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();
|
||
|
||
// ---- 被引用的页面 id:manifest + 各层内存视图 + 退休表 ----
|
||
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.0(B-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.0(A41):**先写 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.0(A41):追加一条 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.0(A41):同 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: 事务快照优先,否则回源 LSM(lsm.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.0(B-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.0(B-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.0(A13):**不再跳过自引用外键**(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.0(A13):**不再跳过自引用外键**(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.0(B-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.0(A13):**不再跳过自引用外键**(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.0(A13):**不再跳过自引用外键**(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 TABLE(v0.4.1) ----
|
||
|
||
/**
|
||
* v0.4.1: ALTER TABLE — 结构变更真正生效于存储:
|
||
* - ADD: 持久化 schema(persistSchemas),行无需修改
|
||
* - 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.0(A41):先写 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.0(A41):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: 记录当前快照涉及的表(回滚后重建索引)—— 此前事务内直写
|
||
// 索引 LSM,savepoint 回滚只还原快照 → 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.0(B-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.0(B-6):表结构**随 manifest 一起提交**(单一提交点)。
|
||
*
|
||
* 修复前 DDL 结束时会单独写一份 `__aria_schemas`:结构变更与存储状态
|
||
* (SSTable meta / 页面 / WAL 水位)各自落盘,中间崩溃就会留下"结构说加过列、
|
||
* 数据里没有"或反之的分裂状态。现在两者在同一次原子提交里生效。
|
||
*/
|
||
private async persistSchemas(): Promise<void> {
|
||
await this.commitManifest();
|
||
}
|
||
|
||
/**
|
||
* v0.8.0(review 修复):表结构记录的**唯一**解析实现。
|
||
*
|
||
* 为什么必须集中且严格:结构记录有两种坏法 —— 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.0(review 修复): 本次打开/修复过程中出现过的**一切损坏迹象**。
|
||
*
|
||
* 为什么需要它而不是只看 `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,不等 compaction(checkpoint 用)
|
||
*/
|
||
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.0(B-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.0(review 修复 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.0(B-6):WAL 检查点的**唯一实现** —— "先算边界 → 提交 manifest → 再删除"。
|
||
*
|
||
* 顺序不可交换(见 `SegmentedWALStore.planKeepFrom` 的说明)。所有需要回收 WAL
|
||
* 空间的路径(打开恢复后、close、repair、周期 checkpoint)都必须走这里,
|
||
* 否则又会出现"同一语义多处实现、只改一处"的老问题。
|
||
*/
|
||
private async advanceWalCheckpoint(): Promise<void> {
|
||
// v0.8.0(review 修复 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.0(B-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.0(A38):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 列表注入 meta(save 后、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 SSTable(4MB)超过 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.0(A41):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: null(IS 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: 单次全表扫描重建全部索引列 —— 此前每个索引列各做一次
|
||
// getAllRows(N 列 × 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 memtable(vacuum 的语义是"把可回收的空间收掉",必须先把内存数据落盘)
|
||
await this.lsm.flush();
|
||
// v0.8.0(B-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');
|
||
}
|
||
}
|
||
}
|