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
+205 -27
View File
@@ -36,7 +36,7 @@
// ---------------------------------------------------------------------------
// 版本
// ---------------------------------------------------------------------------
const VERSION = '0.6.1';
const VERSION = '0.6.2';
/**
* metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑
@@ -401,6 +401,10 @@
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);
@@ -2027,16 +2031,22 @@
}
}
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)
@@ -3918,6 +3928,46 @@
await this.preloadSSTable(id, meta);
}
}
/**
* v0.6.2: 批量预加载多个前缀范围可能命中的所有 SSTable一次 drainChain
* 唯一性检查等"每行一个窄前缀范围"场景用meta 遍历只做一次
* 避免逐行调用 prefetchRange 时每行 drainChain 的性能悬崖
*/
async prefetchPrefixRanges(ranges) {
if (ranges.length === 0)
return;
// v0.6.2: 去重(大批量插入时唯一列值可能重复),减少 meta 遍历开销
const seen = new Set();
const unique = [];
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();
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 预加载用) */
findMetaById(id) {
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
@@ -6493,11 +6543,50 @@
// 后台 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 = [];
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) => {
const val = v.row[colName];
if (val === undefined || val === null)
return null;
const p = `${String(val)}:`;
return [p, `${p}\uffff`];
})
.filter((r) => r !== null));
}
// v0.6.2: 唯一性整批预检(批内互查 + 索引查)—— 失败整批不落库(原子语义)
const batchUnique = new Map();
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();
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))
@@ -6576,15 +6665,48 @@
const walRecords = [];
// v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set();
// 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 = [];
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]}`;
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
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.__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(tableName, String(row[pkCol]), newPk, walRecords, visited);
@@ -6619,7 +6741,9 @@
data: updated,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
this.updateSecondaryIndexes(tableName, newPk, updated, pkChanged ? row : null);
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
}
await this.wal.appendBatch(walRecords);
@@ -7434,6 +7558,40 @@
// =======================================================================
// 二级索引
// =======================================================================
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
uniqueColumns(tableName, schema) {
const cols = [];
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 当前行主键更新路径用于排除自身旧索引条目插入路径无自身条目
*/
checkUniqueSync(tableName, uniqueCols, row, currentPk) {
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.pk;
if (pk !== undefined && pk !== currentPk) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
}
}
}
/** 更新行的二级索引条目 */
updateSecondaryIndexes(tableName, pkValue, newRow, oldRow) {
const schema = this.schemas.get(tableName);
@@ -7533,15 +7691,25 @@
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;
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 = [];
const seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重
for (const val of c.$in) {
@@ -7558,17 +7726,12 @@
}
// $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 }));
}
}
return null;
@@ -10012,12 +10175,27 @@
async executeExplain(stmt) {
const startTime = Date.now();
let result = null;
try {
result = await this.execute(stmt.query);
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;
}
}
catch { /* explain 即使执行失败也返回计划 */ }
const elapsed = Date.now() - startTime;
const rows = Array.isArray(result) ? result.length : 0;
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
let plan = null;
try {