fix(P0): v0.6.2 数据正确性专项 — 深度审计 6 项修复 + 22 回归
- 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:
Vendored
+205
-27
@@ -30,7 +30,7 @@ class DatabaseError extends Error {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
const VERSION = '0.6.1';
|
||||
const VERSION = '0.6.2';
|
||||
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
@@ -395,6 +395,10 @@ class MemoryEngine {
|
||||
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);
|
||||
@@ -2021,16 +2025,22 @@ class KVStoreEngine {
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -3912,6 +3922,46 @@ class LSM {
|
||||
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++) {
|
||||
@@ -6487,11 +6537,50 @@ class AriaEngine {
|
||||
// 后台 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))
|
||||
@@ -6570,15 +6659,48 @@ class AriaEngine {
|
||||
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);
|
||||
@@ -6613,7 +6735,9 @@ class AriaEngine {
|
||||
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);
|
||||
@@ -7428,6 +7552,40 @@ class AriaEngine {
|
||||
// =======================================================================
|
||||
// 二级索引
|
||||
// =======================================================================
|
||||
/** 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);
|
||||
@@ -7527,15 +7685,25 @@ class AriaEngine {
|
||||
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: null(IS 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) {
|
||||
@@ -7552,17 +7720,12 @@ class AriaEngine {
|
||||
}
|
||||
// $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;
|
||||
@@ -10006,12 +10169,27 @@ class QueryExecutor {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user