fix: v0.7.3 数据正确性与边界窗口收尾 — INSERT 语句级原子(三引擎+Aria PK 批内重复)/ 索引列 IS NULL 恒空 / delete RESTRICT 破坏索引 / queryStream 子查询静默空结果 / ALTER DROP 索引残留 / UNIQUE INDEX 存量校验 / SELECT * 别名投影 / WAL BEGIN/ROLLBACK 事务边界 / aria $in 与级联重复扫描性能 / $and 等值下推 / ANALYZE 索引统计 / React-Vue hooks 生命周期 / 迁移主键兜底 + 58 回归
CI / test (18.x) (push) Successful in 17m43s
CI / test (22.x) (push) Successful in 13m45s
CI / test (20.x) (push) Successful in 15m33s
CI / test (24.x) (push) Successful in 24m46s
CI / e2e (push) Successful in 52s

This commit is contained in:
thzxx
2026-08-14 22:53:46 +08:00
parent f8f8d1b2ff
commit 50468b9b0e
29 changed files with 2374 additions and 650 deletions
+137 -71
View File
@@ -6,7 +6,7 @@
*/
import type { IStorageEngine } from '../interface';
import type { QueryPlan, TableSchema, ColumnDef } from '../../constants';
import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants';
import { DatabaseError } from '../../constants';
import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher';
import { checkFieldType, stripUndefinedUpdates } from '../../table/schema';
@@ -557,6 +557,28 @@ export class AriaEngine implements IStorageEngine {
}
await this.lsm.prefetchKeys(validatedRows.map((v) => v.key));
// 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);
const existing = this.currentTxnId
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
: 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.6.2: 唯一约束 — 批量预加载本批唯一列涉及的索引范围(一次 drainChain
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
@@ -595,16 +617,7 @@ export class AriaEngine implements IStorageEngine {
}
for (const { row: validated, pkValue, key } of validatedRows) {
// Check duplicate in LSM + transaction snapshot
const existing = this.currentTxnId
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
: 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',
);
}
// PK 重复已在批预检阶段检查(v0.7.3),此处不再重复查询
if (this.currentTxnId && this.txnSnapshot) {
// Within transaction: buffer to snapshot + MVCC version chain
@@ -889,25 +902,9 @@ export class AriaEngine implements IStorageEngine {
if (visited.has(visitKey)) return;
visited.add(visitKey);
// 阶段 1: RESTRICT 检查
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
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') continue;
const refRows = await this.getAllRows(refTableName);
if (refRows.some((r) => String(r[colName]) === oldPk)) {
throw new DatabaseError(
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION',
);
}
}
}
// 阶段 2: CASCADE / SET NULL
// v0.7.3-perf: 删除冗余的阶段 1 RESTRICT 扫描 —— checkForeignKeyUpdateRestrict
// 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required
// 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
@@ -1312,8 +1309,6 @@ export class AriaEngine implements IStorageEngine {
// v0.4.2-fix: 以索引 LSM 是否已建为准(schema 标记可能因重启恢复而存在,
// 但索引 LSM 未恢复 → 此前静默 return 导致索引永久缺失)
if (this.secondaryIndexes.has(idxKey)) return;
colDef.index = true;
if (unique) colDef.unique = true;
const idxLsm = new LSM({
memtableSizeThreshold: this.config.memtableSizeThreshold,
@@ -1326,16 +1321,36 @@ export class AriaEngine implements IStorageEngine {
await idxLsm.init();
this.secondaryIndexes.set(idxKey, idxLsm);
// 从主 LSM 重建索引数据
const pkCol = this.tablePKs.get(tableName)!;
const rows = await this.getAllRows(tableName);
for (const row of rows) {
const value = row[column];
if (value !== undefined && value !== null) {
idxLsm.put(`${String(value)}:${row[pkCol]}`, { pk: row[pkCol] });
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;
}
await idxLsm.flush();
colDef.index = true;
if (unique) colDef.unique = true;
await this.persistSchemas();
}
@@ -1375,12 +1390,22 @@ export class AriaEngine implements IStorageEngine {
this.currentTxnId = this.mvcc.beginTransaction();
this.txnSnapshot = new Map();
await this.wal.append({
type: WALRecordType.BEGIN,
txnId: this.currentTxnId,
tableName: '',
key: '',
});
// 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: '',
});
} catch (error) {
this.mvcc.rollbackTransaction(this.currentTxnId);
this.currentTxnId = null;
this.txnSnapshot = null;
throw error;
}
}
async commitTransaction(): Promise<void> {
@@ -1416,6 +1441,20 @@ export class AriaEngine implements IStorageEngine {
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) {
@@ -1425,16 +1464,9 @@ export class AriaEngine implements IStorageEngine {
}
}
this.mvcc.rollbackTransaction(this.currentTxnId);
this.mvcc.rollbackTransaction(txnId);
this.txnSnapshot = null;
await this.wal.append({
type: WALRecordType.ROLLBACK,
txnId: this.currentTxnId,
tableName: '',
key: '',
});
this.currentTxnId = null;
// v0.3.3: 事务内直接写入了二级索引 LSM,回滚后全量重建受影响表的索引
@@ -1870,9 +1902,24 @@ export class AriaEngine implements IStorageEngine {
if (!schema) return null;
const pkCol = this.tablePKs.get(tableName)!;
for (const [col, condition] of Object.entries(query.where)) {
// 跳过 $and/$or/$not 逻辑组合
if (col === '$and' || col === '$or' || col === '$not') continue;
// 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);
@@ -1944,18 +1991,30 @@ export class AriaEngine implements IStorageEngine {
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.7.3-perf: 批级预加载全部值的索引范围 + 主表行(各一次 drainChain)——
// 此前逐值 indexScanToRows:每个值一次 prefetchRange + prefetchKeys
// 后台 compaction 长耗时时 N 倍放大(与 v0.6.1 修的 insert 批量预加载
// 性能悬崖同类)。批级预加载后循环内同步 rangeScan/get。
const values = c.$in.map((v) => String(v));
await idxLsm.prefetchPrefixRanges(values.map((v): [string, string] => [v, `${v}\uffff`]));
const results: Record<string, unknown>[] = [];
const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
for (const val of c.$in) {
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, String(val), String(val));
for (const row of rows) {
const pk = String(row[pkCol]);
if (!seenPks.has(pk)) {
const pks: string[] = [];
for (const val of values) {
const entries = 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);
results.push(row);
pks.push(pk);
}
}
}
await this.lsm.prefetchKeys(pks.map((pk) => `${tableName}:${pk}`));
for (const pk of pks) {
const row = this.lsm.get(`${tableName}:${pk}`);
if (row) results.push({ ...row, [pkCol]: pk });
}
return results;
}
// $gt / $gte / $lt / $lte → 范围扫描
@@ -2027,11 +2086,6 @@ export class AriaEngine implements IStorageEngine {
}
}
/** 估算 WAL 大小(字节) */
getWALEstimatedSize(): number {
return this.wal.getBufferedCount() * 200; // 粗略估算每条 ~200B
}
/**
* ANALYZE: 收集表统计信息
* 返回行数、平均行大小、索引深度等
@@ -2040,15 +2094,27 @@ export class AriaEngine implements IStorageEngine {
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: this.lsm.getStats().levelCounts.filter((c: number) => c > 0).length,
sstableCount: this.lsm.getStats().sstableCount,
memtableSize: this.lsm.getStats().memtableSize,
indexDepth,
sstableCount,
memtableSize,
estimatedMemory: this.lsm.getEstimatedMemory(),
};