fix(P0): v0.6.2 数据正确性专项 — 深度审计 6 项修复 + 22 回归
CI / test (22.x) (push) Successful in 17m6s
CI / test (18.x) (push) Failing after 17m45s
CI / test (20.x) (push) Successful in 18m7s
CI / test (24.x) (push) Failing after 14m40s
CI / e2e (push) Successful in 9m54s

- KVStoreEngine 数值主键 update 丢行(P0):String 化主键回查不命中 → 误删 KV 行
  (重启丢数据);改为单次全表扫描 + 受影响集合过滤(兼消 O(N×M) 回查开销)
- update 主键撞已有主键静默覆盖(P0,Memory/Aria):抛 DUPLICATE_KEY,事务路径同拦截
- Aria 二级索引范围查询边界算法错误(P1):Number(v)±1 构造 key 漏小数/字符串数据;
  改全索引扫描 + matchWhere 过滤,边界语义统一
- Aria 索引列 IS NULL 返回空(P1):null 等值/含 null 的 IN 不走索引(回退全表)
- AriaEngine unique 约束未强制(P1):insert 整批预检(批内互查+索引扫描,失败整批
  不落库)+ update 排除自身旧条目检查;新增 LSM.prefetchPrefixRanges 批量预加载
- 非主键 update 索引旧值残留:统一传旧行清理(消除唯一性误报与索引膨胀)
- EXPLAIN 写语句产生真实副作用(P2):仅 SELECT 执行,UPDATE/DELETE 用 count 估算

测试 1092 → 1114(70 套件);行覆盖率 89.6%;版本 0.6.2
This commit is contained in:
thzxx
2026-08-13 09:51:26 +08:00
parent a91ac368e2
commit ef1934a38c
20 changed files with 1284 additions and 118 deletions
+1 -1
View File
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
// 版本
// ---------------------------------------------------------------------------
export const VERSION = '0.6.1';
export const VERSION = '0.6.2';
+138 -11
View File
@@ -542,13 +542,55 @@ export class AriaEngine implements IStorageEngine {
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
// 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
// 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。
await this.lsm.prefetchKeys(rows.map((r) => `${tableName}:${String(r[pkCol])}`));
//
// 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]);
const key = `${tableName}:${pkValue}`;
validatedRows.push({ row: validated, pkValue, key: `${tableName}:${pkValue}` });
}
await this.lsm.prefetchKeys(validatedRows.map((v) => v.key));
// v0.6.2: 唯一约束 — 批量预加载本批唯一列涉及的索引范围(一次 drainChain
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
await idxLsm.prefetchPrefixRanges(
validatedRows
.map((v): [string, string] | null => {
const val = v.row[colName];
if (val === undefined || val === null) return null;
const p = `${String(val)}:`;
return [p, `${p}\uffff`];
})
.filter((r): r is [string, string] => r !== null),
);
}
// 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);
this.checkUniqueSync(tableName, [colName], validated, pkValue);
}
}
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))
@@ -650,6 +692,25 @@ export class AriaEngine implements IStorageEngine {
// v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set<string>();
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
const ranges: [string, string][] = [];
if (updates[colName] !== undefined && updates[colName] !== null) {
const p = `${String(updates[colName])}:`;
ranges.push([p, `${p}\uffff`]);
} else if (!(colName in updates)) {
for (const row of rows) {
const val = row[colName];
if (val === undefined || val === null) continue;
const p = `${String(val)}:`;
ranges.push([p, `${p}\uffff`]);
}
}
await idxLsm.prefetchPrefixRanges(ranges);
}
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
const key = `${tableName}:${row[pkCol]}`;
@@ -658,10 +719,28 @@ export class AriaEngine implements IStorageEngine {
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(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) ?? this.lsm.get(newKey))
: 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',
);
}
}
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(
@@ -699,7 +778,9 @@ export class AriaEngine implements IStorageEngine {
});
// 更新二级索引(主键变更时旧索引条目一并清理)
this.updateSecondaryIndexes(tableName, newPk, updated, pkChanged ? row : null);
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
}
@@ -1556,6 +1637,46 @@ export class AriaEngine implements IStorageEngine {
// 二级索引
// =======================================================================
/** 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.6.2: 同步唯一性检查(须在批次级 prefetchPrefixRanges 之后调用,循环内无 await)。
* 索引不含 null 条目(null 值不受唯一约束,与 MemoryEngine 语义一致)。
* @param currentPk 当前行主键(更新路径用于排除自身旧索引条目;插入路径无自身条目)
*/
private checkUniqueSync(
tableName: string,
uniqueCols: string[],
row: Record<string, unknown>,
currentPk: string,
): 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 = 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,
@@ -1658,15 +1779,22 @@ export class AriaEngine implements IStorageEngine {
// $eq → 精确查找
if (typeof condition !== 'object' || condition === null) {
// v0.6.2-fix(P1): IS NULL(条件为 null)不走索引 —— 索引不含 null 条目,
// String(null)="null" 查找返回空并短路全表 → 索引列 IS NULL 恒空
if (condition === null) continue;
return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition));
}
const c = condition as Record<string, unknown>;
if ('$eq' in c) {
// v0.6.2-fix(P1): 同上,$eq: nullIS NULL)不走索引
if (c.$eq === null) continue;
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;
const results: Record<string, unknown>[] = [];
const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
for (const val of c.$in) {
@@ -1683,13 +1811,12 @@ export class AriaEngine implements IStorageEngine {
}
// $gt / $gte / $lt / $lte → 范围扫描
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
let startKey = '';
let endKey = '\uffff';
if (c.$gt !== undefined) startKey = `${String(Number(c.$gt) + 1)}:`;
else if (c.$gte !== undefined) startKey = `${String(c.$gte)}:`;
if (c.$lt !== undefined) endKey = `${String(Number(c.$lt) - 1)}:\uffff`;
else if (c.$lte !== undefined) endKey = `${String(c.$lte)}:\uffff`;
return this.indexScanToRows(tableName, pkCol, idxLsm, startKey, endKey);
// 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 }));
}
}
+37
View File
@@ -351,6 +351,43 @@ export class LSM {
}
}
/**
* v0.6.2: 批量预加载多个前缀范围可能命中的所有 SSTable(一次 drainChain)。
* 唯一性检查等"每行一个窄前缀范围"场景用:meta 遍历只做一次,
* 避免逐行调用 prefetchRange 时每行 drainChain 的性能悬崖。
*/
async prefetchPrefixRanges(ranges: [string, string][]): Promise<void> {
if (ranges.length === 0) return;
// v0.6.2: 去重(大批量插入时唯一列值可能重复),减少 meta 遍历开销
const seen = new Set<string>();
const unique: [string, string][] = [];
for (const r of ranges) {
const k = `${r[0]}\u0000${r[1]}`;
if (!seen.has(k)) {
seen.add(k);
unique.push(r);
}
}
if (unique.length === 0) return;
await this.drainChain();
this.trimCache();
const toLoad = new Set<number>();
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
for (const meta of this.levels[level]) {
if (this.sstableCache.has(meta.id)) continue;
for (const [startKey, endKey] of unique) {
if (endKey < meta.minKey || startKey > meta.maxKey) continue;
toLoad.add(meta.id);
break;
}
}
}
for (const id of toLoad) {
const meta = this.findMetaById(id);
await this.preloadSSTable(id, meta);
}
}
/** 按 id 查找 SSTable 元数据(prefetch 预加载用) */
private findMetaById(id: number): SSTableMeta | undefined {
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
+14 -7
View File
@@ -294,15 +294,22 @@ export class KVStoreEngine implements IStorageEngine {
deletes.push(...diff.deletes);
}
} else {
// 增量重写受影响行
for (const pk of affected) {
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } });
if (row.length > 0) {
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0]));
} else {
deletes.push(this.rowKey(tableName, pk));
// v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行,
// 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。
// 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。
const affectedSet = new Set(affected);
const allRows = await this.memory.find(tableName, { table: tableName });
for (const row of allRows) {
const pkStr = String(row[pkCol]);
if (affectedSet.has(pkStr)) {
puts[this.rowKey(tableName, pkStr)] = enc(JSON.stringify(row));
affectedSet.delete(pkStr);
}
}
// 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行
for (const pk of affectedSet) {
deletes.push(this.rowKey(tableName, pk));
}
// 级联影响表(SET NULL/CASCADE 外键)整表 diff
for (const t of await this.affectedTables(tableName)) {
if (t === tableName) continue;
+7
View File
@@ -194,6 +194,13 @@ export class MemoryEngine implements IStorageEngine {
this.validateRow(schema, updated);
this.checkUniqueness(schema, updated);
const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
'DUPLICATE_KEY',
);
}
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
+16 -4
View File
@@ -183,11 +183,23 @@ export class QueryExecutor {
private async executeExplain(stmt: { query: Statement }): Promise<Record<string, unknown>> {
const startTime = Date.now();
let result: unknown = null;
try {
result = await this.execute(stmt.query);
} catch { /* explain 即使执行失败也返回计划 */ }
let rows = 0;
// v0.6.2-fix: EXPLAIN 不得真实执行写语句 —— 此前 EXPLAIN DELETE/UPDATE 会产生
// 真实副作用(删/改数据)。仅 SELECT 类语句执行(只读);UPDATE/DELETE 用
// count 估算影响行数(无副作用);INSERT/DDL 仅输出计划不执行。
if (stmt.query.type === 'SELECT' || stmt.query.type === 'SELECT_UNION') {
try {
result = await this.execute(stmt.query);
} catch { /* explain 即使执行失败也返回计划 */ }
rows = Array.isArray(result) ? result.length : 0;
} else if (stmt.query.type === 'UPDATE' || stmt.query.type === 'DELETE') {
try {
const plan = compileStatement(stmt.query);
rows = await this.engine.count(plan.table, plan);
} catch { rows = 0; }
}
const elapsed = Date.now() - startTime;
const rows = Array.isArray(result) ? result.length : 0;
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
let plan: import('../constants').QueryPlan | null = null;