From 765df805ebda54d7da25548233960f080e1e10c0 Mon Sep 17 00:00:00 2001 From: thzxx Date: Mon, 14 Sep 2026 22:02:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(A1/A2):=20UPDATE=20=E6=89=B9=E5=86=85?= =?UTF-8?q?=E4=B8=BB=E9=94=AE=E7=A2=B0=E6=92=9E=E4=B8=A2=E8=A1=8C=20+=20AL?= =?UTF-8?q?TER=20ADD=20UNIQUE=20=E5=BD=A2=E5=90=8C=E8=99=9A=E8=AE=BE?= =?UTF-8?q?=EF=BC=88=E5=9B=9B=E5=BC=95=E6=93=8E=E6=A0=B9=E6=B2=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 用例,四引擎参数化)。 --- src/engine/aria/index.ts | 29 +++++++++++ src/engine/memory.ts | 56 ++++++++++++++++++++++ tests/v080-atomicity.test.ts | 93 ++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 tests/v080-atomicity.test.ts diff --git a/src/engine/aria/index.ts b/src/engine/aria/index.ts index 4e76988..28765fb 100644 --- a/src/engine/aria/index.ts +++ b/src/engine/aria/index.ts @@ -826,6 +826,14 @@ export class AriaEngine implements IStorageEngine { // (无事务下语句级部分提交 + 崩溃后进一步不一致)。 const planned: { row: Record; pk: string; key: string; updated: Record; newPk: string; pkChanged: boolean }[] = []; const batchUnique: Map> = new Map(); + /** + * v0.8.0 根治:批内新主键互查(与 MemoryEngine 对齐)。 + * + * 此前只检查"新主键是否已存在于**语句执行前**的表",看不到同一语句内其它行 + * 即将写入的新主键。于是 `UPDATE t SET id = 'X'`(匹配 3 行)在阶段 2 逐行 + * 覆盖同一 LSM key —— 返回 affected=3,表中却只剩 1 行(静默丢行,实测)。 + */ + const batchNewPks = new Set(); // 阶段 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; } diff --git a/src/engine/memory.ts b/src/engine/memory.ts index 72dec7c..af87209 100644 --- a/src/engine/memory.ts +++ b/src/engine/memory.ts @@ -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(); + 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; updated: Record; newPk: string }[] = []; const batchUnique: Map> = 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(); // 阶段 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 预检(引用表依赖行检查,任何修改前) diff --git a/tests/v080-atomicity.test.ts b/tests/v080-atomicity.test.ts new file mode 100644 index 0000000..1452a24 --- /dev/null +++ b/tests/v080-atomicity.test.ts @@ -0,0 +1,93 @@ +/** + * v0.8.0 回归 —— 语句级原子性与 ALTER 约束(四引擎一致性) + * + * A1 UPDATE 批内主键碰撞静默丢行 + * 阶段 1 只用 `table.has(newPk)` 与**语句执行前**的表比对,看不到同一语句内其它行 + * 即将写入的新主键;阶段 2 逐行写同一个 key 互相覆盖。实测 `UPDATE t SET id='X'` + * (匹配 3 行)返回 affected=3,而表中只剩 1 行。 + * INSERT 路径在 v0.7.3 已做批内相互检查,UPDATE 漏了。 + * + * A2 ALTER ADD COLUMN ... UNIQUE 形同虚设 + * 只写 schema 而不建索引桶/索引 LSM,而唯一性预检完全依赖索引 —— 重复值可任意写入。 + * Memory 侧后果更严重:重启时 createTable 依 schema 建桶、回灌第二行触发 + * UNIQUE_VIOLATION,而该异常被引擎 open 路径吞掉 → **行静默消失**(实测 2 行变 1 行)。 + * + * 两者都在四个引擎上验证:memory / disk / hybrid / aria。 + */ +import { MetonaSqlark } from '../src/core'; + +const MODES: Array<[string, Record]> = [ + ['memory', {}], + ['disk', {}], + ['hybrid', {}], + ['aria', { diskEngine: 'memory' }], +]; + +describe('[v0.8.0] A1 UPDATE 批内主键碰撞', () => { + test('多行改到同一新主键 → 整体拒绝且不丢行', async () => { + for (const [mode, extra] of MODES) { + const db = await MetonaSqlark.create({ name: `v080-a1-${mode}`, mode, ...extra } as never); + await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } }); + await db.query("INSERT INTO t VALUES ('a',1),('b',2),('c',3)"); + + await expect(db.query("UPDATE t SET id = 'X'")) + .rejects.toMatchObject({ code: 'DUPLICATE_KEY' }); + + const rows = await db.query('SELECT * FROM t ORDER BY id') as Array<{ id: string }>; + // 关键:三行必须全部保留(此前会被逐行覆盖成 1 行) + expect(rows.map((r) => r.id)).toEqual(['a', 'b', 'c']); + await db.close(); + } + }); + + test('合法的单行主键变更仍然可用', async () => { + for (const [mode, extra] of MODES) { + const db = await MetonaSqlark.create({ name: `v080-a1b-${mode}`, mode, ...extra } as never); + await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } }); + await db.query("INSERT INTO t VALUES ('a',1),('b',2)"); + + const affected = await db.query("UPDATE t SET id = 'z' WHERE id = 'a'"); + expect(affected).toBe(1); + + const rows = await db.query('SELECT * FROM t ORDER BY id') as Array<{ id: string }>; + expect(rows.map((r) => r.id)).toEqual(['b', 'z']); + await db.close(); + } + }); +}); + +describe('[v0.8.0] A2 ALTER ADD UNIQUE 约束', () => { + test('ALTER 添加的 UNIQUE 必须真正生效', async () => { + for (const [mode, extra] of MODES) { + const db = await MetonaSqlark.create({ name: `v080-a2-${mode}`, mode, ...extra } as never); + await db.defineTable('t', { id: { type: 'string', primaryKey: true } }); + await db.query("INSERT INTO t VALUES ('1')"); + await db.query('ALTER TABLE t ADD COLUMN email STRING UNIQUE'); + await db.query("UPDATE t SET email = 'a@x.com' WHERE id = '1'"); + + // 重复值必须被拒绝(此前四个引擎全部静默接受) + await expect(db.query("INSERT INTO t VALUES ('2', 'a@x.com')")) + .rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' }); + + const rows = await db.query('SELECT COUNT(*) AS c FROM t') as Array<{ c: number }>; + expect(rows[0].c).toBe(1); + await db.close(); + } + }); + + test('CREATE UNIQUE INDEX 存量重复必须报错,且数据不被破坏', async () => { + for (const [mode, extra] of MODES) { + const db = await MetonaSqlark.create({ name: `v080-a2r-${mode}`, mode, ...extra } as never); + await db.defineTable('t', { id: { type: 'string', primaryKey: true }, email: { type: 'string' } }); + await db.query("INSERT INTO t VALUES ('1','dup@x'),('2','dup@x')"); + + await expect(db.query('CREATE UNIQUE INDEX uq ON t (email)')) + .rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' }); + + // 失败不得破坏既有数据 + const rows = await db.query('SELECT COUNT(*) AS c FROM t') as Array<{ c: number }>; + expect(rows[0].c).toBe(2); + await db.close(); + } + }); +});