fix(A1/A2): UPDATE 批内主键碰撞丢行 + ALTER ADD UNIQUE 形同虚设(四引擎根治)

A1 UPDATE 批内新主键碰撞 → 静默丢行
  阶段 1 只用 `table.has(newPk)` 与**语句执行前**的表比对,看不到同一语句内其它行
  即将写入的新主键;阶段 2 逐行写同一个 key 互相覆盖。
  实测 `UPDATE t SET id='X'`(匹配 3 行)返回 affected=3,表中只剩 1 行。
  INSERT 路径在 v0.7.3 已做批内 Set 互查,UPDATE 漏了 —— 典型的"同类修复只打一半"。

  根治:MemoryEngine 与 AriaEngine 的阶段 1 增加批内新主键 Set 互查,
  任一行撞车即整体拒绝(DUPLICATE_KEY),不写入任何一行。
  保守语义说明:这也会拒绝"两行互换主键"(A:x→y, B:y→x,最终状态合法)——
  与既有的"唯一值交换更新保守拒绝"一致,宁可显式报错也不静默丢行。

A2 ALTER ADD COLUMN ... UNIQUE 形同虚设
  只写 schema 不建索引桶(Memory)/索引 LSM(Aria),而唯一性预检完全依赖索引
  (`tableIndexes.get(col)` 缺失即整段跳过)—— 重复值可任意写入,四个引擎全部接受。
  Memory 侧后果更严重:重启时 createTable 依 schema 建桶、回灌第二行触发
  UNIQUE_VIOLATION,而该异常被 KVStoreEngine.open 的 catch 吞掉 → **行静默消失**。

  根治:
  - MemoryEngine.alterTable ADD:index/unique 列建立索引桶并回填;回填前做存量
    唯一性校验,重复则回滚本次 ALTER(删列 + 删桶)并抛 UNIQUE_VIOLATION。
  - AriaEngine.alterTable ADD:复用既有 createIndex(它已实现"回填 + 存量唯一性
    校验 + 失败原子清理",是 v0.6.2/v0.7.3 的成果)—— 不重复实现以免再次漂移。
  - KVStore/Hybrid 通过 Memory 引擎自动获得同等语义。

新增 tests/v080-atomicity.test.ts(4 用例,四引擎参数化)。
This commit is contained in:
thzxx
2026-09-14 22:02:30 +08:00
parent 7c7ecb8b1d
commit 765df805eb
3 changed files with 178 additions and 0 deletions
+29
View File
@@ -826,6 +826,14 @@ export class AriaEngine implements IStorageEngine {
// (无事务下语句级部分提交 + 崩溃后进一步不一致)。
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) {
@@ -858,6 +866,14 @@ export class AriaEngine implements IStorageEngine {
'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 });
@@ -1350,6 +1366,19 @@ export class AriaEngine implements IStorageEngine {
throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS');
}
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;
}