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:
@@ -125,6 +125,38 @@ export class MemoryEngine 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 必须建立二级索引桶。
|
||||
//
|
||||
// 此前只写 schema.columns 而不建桶,而唯一性预检完全依赖索引桶
|
||||
// (`tableIndexes.get(colName)` 缺失即整段跳过)—— 于是
|
||||
// `ALTER TABLE t ADD COLUMN email STRING UNIQUE` 之后插入重复 email
|
||||
// **不会报错**;close/reopen 时 createTable 依 schema 建桶、回灌第 2 行
|
||||
// 触发 UNIQUE_VIOLATION 而异常被引擎 open 路径吞掉 → **行静默消失**。
|
||||
if (column.index || column.unique) {
|
||||
if (!this.indexes.has(tableName)) this.indexes.set(tableName, new Map());
|
||||
const tableIndexes = this.indexes.get(tableName)!;
|
||||
if (!tableIndexes.has(column.name)) tableIndexes.set(column.name, new Map());
|
||||
// 已存在行:先校验存量唯一性(重复则回滚本次 ALTER),再回填索引桶
|
||||
const colIndex = tableIndexes.get(column.name)!;
|
||||
const table = this.tables.get(tableName)!;
|
||||
const seen = new Set<unknown>();
|
||||
for (const [pk, row] of table) {
|
||||
const value = row[column.name];
|
||||
if (value === null || value === undefined) continue; // null 不受唯一约束
|
||||
if (column.unique && seen.has(value)) {
|
||||
tableIndexes.delete(column.name);
|
||||
delete schema.columns[column.name];
|
||||
throw new DatabaseError(
|
||||
`Duplicate value "${String(value)}" for UNIQUE column "${column.name}" in table "${tableName}"`,
|
||||
'UNIQUE_VIOLATION',
|
||||
);
|
||||
}
|
||||
seen.add(value);
|
||||
let pks = colIndex.get(value);
|
||||
if (!pks) { pks = new Set(); colIndex.set(value, pks); }
|
||||
pks.add(pk);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!schema.columns[column.name]) {
|
||||
@@ -267,6 +299,20 @@ export class MemoryEngine implements IStorageEngine {
|
||||
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
|
||||
const planned: { pk: string; row: Record<string, unknown>; updated: Record<string, unknown>; newPk: string }[] = [];
|
||||
const batchUnique: Map<string, Set<unknown>> = new Map();
|
||||
/**
|
||||
* v0.8.0 根治:批内新主键互查。
|
||||
*
|
||||
* 此前阶段 1 只用 `table.has(newPk)` 与**语句执行前的表**比对,看不到同一语句内
|
||||
* 其它行即将写入的新主键。于是 `UPDATE t SET id = 'X'`(匹配 2 行)在阶段 2
|
||||
* 逐行 `table.set(newPk, ...)` 相互覆盖 —— 返回 affected=2,表中却只剩 1 行
|
||||
* (静默丢行)。INSERT 路径在 v0.7.3 已做批内 PK Set 互查,UPDATE 漏了。
|
||||
*
|
||||
* 保守拒绝策略:同一语句内两行改到同一新主键必然互相覆盖,直接报错。
|
||||
* 注意这也会拒绝"两行互换主键"(A:x→y, B:y→x)这种最终状态合法的写法 ——
|
||||
* 那属于需要基于最终状态判定的场景,宁可显式报错也不静默丢行
|
||||
* (与既有的"唯一值交换更新保守拒绝"语义一致)。
|
||||
*/
|
||||
const batchNewPks = new Set<string>();
|
||||
|
||||
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
|
||||
for (const [pk, row] of table) {
|
||||
@@ -282,6 +328,16 @@ export class MemoryEngine implements IStorageEngine {
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
// v0.8.0: 批内互查 —— 同一语句内两行改到同一新主键 → 整体拒绝(不得静默覆盖)
|
||||
if (newPk !== pk) {
|
||||
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({ pk, row, updated, newPk });
|
||||
}
|
||||
// 阶段 1b:主键变更 RESTRICT 预检(引用表依赖行检查,任何修改前)
|
||||
|
||||
Reference in New Issue
Block a user