fix: v0.7.3 数据正确性与边界窗口收尾 — INSERT 语句级原子(三引擎+Aria PK 批内重复)/ 索引列 IS NULL 恒空 / delete RESTRICT 破坏索引 / queryStream 子查询静默空结果 / ALTER DROP 索引残留 / UNIQUE INDEX 存量校验 / SELECT * 别名投影 / WAL BEGIN/ROLLBACK 事务边界 / aria $in 与级联重复扫描性能 / $and 等值下推 / ANALYZE 索引统计 / React-Vue hooks 生命周期 / 迁移主键兜底 + 58 回归
This commit is contained in:
+137
-71
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from '../interface';
|
||||
import type { QueryPlan, TableSchema, ColumnDef } from '../../constants';
|
||||
import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants';
|
||||
import { DatabaseError } from '../../constants';
|
||||
import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher';
|
||||
import { checkFieldType, stripUndefinedUpdates } from '../../table/schema';
|
||||
@@ -557,6 +557,28 @@ export class AriaEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
await this.lsm.prefetchKeys(validatedRows.map((v) => v.key));
|
||||
// v0.7.3: 主键批内互查 + 预检 —— 此前 PK 重复检查在写入循环内:
|
||||
// 第 N 行重复抛错时,前 N-1 行已 put LSM 且其 WAL 记录随 appendBatch 一起
|
||||
// 丢失 → 语句级部分提交 + 内存/WAL 不一致(与 v0.6.2 的 unique 预检同一阶段)。
|
||||
const pkSet = new Set<string>();
|
||||
for (const { pkValue, key } of validatedRows) {
|
||||
if (pkSet.has(pkValue)) {
|
||||
throw new DatabaseError(
|
||||
`Duplicate primary key "${pkValue}" in table "${tableName}"`,
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
pkSet.add(pkValue);
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
|
||||
: this.lsm.get(key);
|
||||
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
|
||||
throw new DatabaseError(
|
||||
`Duplicate primary key "${pkValue}" in table "${tableName}"`,
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
}
|
||||
// v0.6.2: 唯一约束 — 批量预加载本批唯一列涉及的索引范围(一次 drainChain)
|
||||
for (const colName of uniqueCols) {
|
||||
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
|
||||
@@ -595,16 +617,7 @@ export class AriaEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
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))
|
||||
: this.lsm.get(key);
|
||||
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
|
||||
throw new DatabaseError(
|
||||
`Duplicate primary key "${pkValue}" in table "${tableName}"`,
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
// PK 重复已在批预检阶段检查(v0.7.3),此处不再重复查询
|
||||
|
||||
if (this.currentTxnId && this.txnSnapshot) {
|
||||
// Within transaction: buffer to snapshot + MVCC version chain
|
||||
@@ -889,25 +902,9 @@ export class AriaEngine implements IStorageEngine {
|
||||
if (visited.has(visitKey)) return;
|
||||
visited.add(visitKey);
|
||||
|
||||
// 阶段 1: RESTRICT 检查
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName) continue;
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onUpdate) continue;
|
||||
const [refTable] = colDef.references.split('.');
|
||||
if (refTable !== tableName) continue;
|
||||
if (colDef.onUpdate !== 'RESTRICT') continue;
|
||||
const refRows = await this.getAllRows(refTableName);
|
||||
if (refRows.some((r) => String(r[colName]) === oldPk)) {
|
||||
throw new DatabaseError(
|
||||
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
|
||||
'FOREIGN_KEY_VIOLATION',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段 2: CASCADE / SET NULL
|
||||
// v0.7.3-perf: 删除冗余的阶段 1 RESTRICT 扫描 —— checkForeignKeyUpdateRestrict
|
||||
// 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required,
|
||||
// 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName) continue;
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
@@ -1312,8 +1309,6 @@ export class AriaEngine implements IStorageEngine {
|
||||
// v0.4.2-fix: 以索引 LSM 是否已建为准(schema 标记可能因重启恢复而存在,
|
||||
// 但索引 LSM 未恢复 → 此前静默 return 导致索引永久缺失)
|
||||
if (this.secondaryIndexes.has(idxKey)) return;
|
||||
colDef.index = true;
|
||||
if (unique) colDef.unique = true;
|
||||
|
||||
const idxLsm = new LSM({
|
||||
memtableSizeThreshold: this.config.memtableSizeThreshold,
|
||||
@@ -1326,16 +1321,36 @@ export class AriaEngine implements IStorageEngine {
|
||||
await idxLsm.init();
|
||||
this.secondaryIndexes.set(idxKey, idxLsm);
|
||||
|
||||
// 从主 LSM 重建索引数据
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
const rows = await this.getAllRows(tableName);
|
||||
for (const row of rows) {
|
||||
const value = row[column];
|
||||
if (value !== undefined && value !== null) {
|
||||
idxLsm.put(`${String(value)}:${row[pkCol]}`, { pk: row[pkCol] });
|
||||
try {
|
||||
// 从主 LSM 重建索引数据
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
const rows = await this.getAllRows(tableName);
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const value = row[column];
|
||||
if (value !== undefined && value !== null) {
|
||||
const v = String(value);
|
||||
// v0.7.3: UNIQUE 索引回填校验存量唯一性 —— 此前重复数据静默建索引
|
||||
// (SQLite 语义应报错),与 MemoryEngine 对齐
|
||||
if (unique && seen.has(v)) {
|
||||
throw new DatabaseError(
|
||||
`Unique index on column "${column}" in table "${tableName}" cannot be created: duplicate value "${v}"`,
|
||||
'UNIQUE_VIOLATION',
|
||||
);
|
||||
}
|
||||
seen.add(v);
|
||||
idxLsm.put(`${v}:${row[pkCol]}`, { pk: row[pkCol] });
|
||||
}
|
||||
}
|
||||
await idxLsm.flush();
|
||||
} catch (error) {
|
||||
// 回填失败(唯一冲突):清理半初始化索引(内存 + 存储),标志未落,保持原子语义
|
||||
this.secondaryIndexes.delete(idxKey);
|
||||
try { await idxLsm.clear(); } catch { /* 清理失败不阻塞 */ }
|
||||
throw error;
|
||||
}
|
||||
await idxLsm.flush();
|
||||
colDef.index = true;
|
||||
if (unique) colDef.unique = true;
|
||||
await this.persistSchemas();
|
||||
}
|
||||
|
||||
@@ -1375,12 +1390,22 @@ export class AriaEngine implements IStorageEngine {
|
||||
this.currentTxnId = this.mvcc.beginTransaction();
|
||||
this.txnSnapshot = new Map();
|
||||
|
||||
await this.wal.append({
|
||||
type: WALRecordType.BEGIN,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
// v0.7.3-fix: WAL BEGIN 写失败回滚内存事务状态 —— 此前 append 抛错(full 模式)
|
||||
// 时 currentTxnId 已设置 → TX_ACTIVE 永久泄漏(后续无法开始新事务)。
|
||||
// 回滚 mvcc 登记 + 快照后重抛,调用方可重试。
|
||||
try {
|
||||
await this.wal.append({
|
||||
type: WALRecordType.BEGIN,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
} catch (error) {
|
||||
this.mvcc.rollbackTransaction(this.currentTxnId);
|
||||
this.currentTxnId = null;
|
||||
this.txnSnapshot = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async commitTransaction(): Promise<void> {
|
||||
@@ -1416,6 +1441,20 @@ export class AriaEngine implements IStorageEngine {
|
||||
async rollbackTransaction(): Promise<void> {
|
||||
if (!this.currentTxnId) throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
|
||||
const txnId = this.currentTxnId;
|
||||
|
||||
// v0.7.3-fix: 先持久化 WAL ROLLBACK,再回滚内存 —— 与 commitTransaction 的
|
||||
// "WAL 领先内存"(v0.4.3-fix)对齐。此前内存先回滚、ROLLBACK 记录后写:
|
||||
// full 模式写失败时崩溃重放无 ROLLBACK 记录 → 已回滚事务的数据复活。
|
||||
// 现在写失败 → 内存未回滚、事务仍活跃(调用方可重试),崩溃后重放
|
||||
// 看到 ROLLBACK 记录同样不会复活数据。
|
||||
await this.wal.append({
|
||||
type: WALRecordType.ROLLBACK,
|
||||
txnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
|
||||
// v0.3.3: 记录事务涉及的表(用于回滚后重建索引,消除索引残留)
|
||||
const affectedTables = new Set<string>();
|
||||
if (this.txnSnapshot) {
|
||||
@@ -1425,16 +1464,9 @@ export class AriaEngine implements IStorageEngine {
|
||||
}
|
||||
}
|
||||
|
||||
this.mvcc.rollbackTransaction(this.currentTxnId);
|
||||
this.mvcc.rollbackTransaction(txnId);
|
||||
this.txnSnapshot = null;
|
||||
|
||||
await this.wal.append({
|
||||
type: WALRecordType.ROLLBACK,
|
||||
txnId: this.currentTxnId,
|
||||
tableName: '',
|
||||
key: '',
|
||||
});
|
||||
|
||||
this.currentTxnId = null;
|
||||
|
||||
// v0.3.3: 事务内直接写入了二级索引 LSM,回滚后全量重建受影响表的索引
|
||||
@@ -1870,9 +1902,24 @@ export class AriaEngine implements IStorageEngine {
|
||||
if (!schema) return null;
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
|
||||
for (const [col, condition] of Object.entries(query.where)) {
|
||||
// 跳过 $and/$or/$not 逻辑组合
|
||||
if (col === '$and' || col === '$or' || col === '$not') continue;
|
||||
// v0.7.3: 递归展开 $and 中的等值条件 —— 此前仅顶层键,
|
||||
// `WHERE a AND b`(解析为顶层 $and)永远全表扫描,索引形同虚设。
|
||||
// $or/$not 语义不适用单索引下推,保守跳过。命中索引后 find 仍以
|
||||
// 全条件 matchWhere 过滤(子集语义安全)。
|
||||
const flat: [string, unknown][] = [];
|
||||
const collect = (w: WhereCondition): void => {
|
||||
for (const [k, v] of Object.entries(w)) {
|
||||
if (k === '$and') {
|
||||
for (const sub of (v as WhereCondition[])) collect(sub);
|
||||
continue;
|
||||
}
|
||||
if (k === '$or' || k === '$not' || k === '$exists') continue;
|
||||
flat.push([k, v]);
|
||||
}
|
||||
};
|
||||
collect(query.where);
|
||||
|
||||
for (const [col, condition] of flat) {
|
||||
|
||||
const colDef = schema.columns[col];
|
||||
const hasIndex = colDef && (colDef.index || colDef.unique || colDef.primaryKey);
|
||||
@@ -1944,18 +1991,30 @@ export class AriaEngine implements IStorageEngine {
|
||||
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;
|
||||
// v0.7.3-perf: 批级预加载全部值的索引范围 + 主表行(各一次 drainChain)——
|
||||
// 此前逐值 indexScanToRows:每个值一次 prefetchRange + prefetchKeys,
|
||||
// 后台 compaction 长耗时时 N 倍放大(与 v0.6.1 修的 insert 批量预加载
|
||||
// 性能悬崖同类)。批级预加载后循环内同步 rangeScan/get。
|
||||
const values = c.$in.map((v) => String(v));
|
||||
await idxLsm.prefetchPrefixRanges(values.map((v): [string, string] => [v, `${v}\uffff`]));
|
||||
const results: Record<string, unknown>[] = [];
|
||||
const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
|
||||
for (const val of c.$in) {
|
||||
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, String(val), String(val));
|
||||
for (const row of rows) {
|
||||
const pk = String(row[pkCol]);
|
||||
if (!seenPks.has(pk)) {
|
||||
const pks: string[] = [];
|
||||
for (const val of values) {
|
||||
const entries = idxLsm.rangeScan(val, `${val}\uffff`);
|
||||
for (const [, idxEntry] of entries) {
|
||||
const pk = (idxEntry as { pk?: string }).pk;
|
||||
if (pk && !seenPks.has(pk)) {
|
||||
seenPks.add(pk);
|
||||
results.push(row);
|
||||
pks.push(pk);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.lsm.prefetchKeys(pks.map((pk) => `${tableName}:${pk}`));
|
||||
for (const pk of pks) {
|
||||
const row = this.lsm.get(`${tableName}:${pk}`);
|
||||
if (row) results.push({ ...row, [pkCol]: pk });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
// $gt / $gte / $lt / $lte → 范围扫描
|
||||
@@ -2027,11 +2086,6 @@ export class AriaEngine implements IStorageEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** 估算 WAL 大小(字节) */
|
||||
getWALEstimatedSize(): number {
|
||||
return this.wal.getBufferedCount() * 200; // 粗略估算每条 ~200B
|
||||
}
|
||||
|
||||
/**
|
||||
* ANALYZE: 收集表统计信息
|
||||
* 返回行数、平均行大小、索引深度等
|
||||
@@ -2040,15 +2094,27 @@ export class AriaEngine implements IStorageEngine {
|
||||
this.ensureOpen();
|
||||
this.ensureTable(tableName);
|
||||
const rows = await this.getAllRows(tableName);
|
||||
// v0.7.3: 统计汇总主 LSM + 该表全部二级索引 LSM —— 此前只统计主 LSM,
|
||||
// 表带多个索引时索引深度/SSTable 数量严重低估
|
||||
let sstableCount = this.lsm.getStats().sstableCount;
|
||||
let memtableSize = this.lsm.getStats().memtableSize;
|
||||
let indexDepth = this.lsm.getStats().levelCounts.filter((c: number) => c > 0).length;
|
||||
for (const [idxKey, idxLsm] of this.secondaryIndexes) {
|
||||
if (!idxKey.startsWith(`${tableName}:idx:`)) continue;
|
||||
const s = idxLsm.getStats();
|
||||
sstableCount += s.sstableCount;
|
||||
memtableSize += s.memtableSize;
|
||||
indexDepth = Math.max(indexDepth, s.levelCounts.filter((c: number) => c > 0).length);
|
||||
}
|
||||
const stats: Record<string, unknown> = {
|
||||
table: tableName,
|
||||
rowCount: rows.length,
|
||||
avgRowSize: rows.length > 0
|
||||
? Math.round(rows.reduce((s, r) => s + JSON.stringify(r).length, 0) / rows.length)
|
||||
: 0,
|
||||
indexDepth: this.lsm.getStats().levelCounts.filter((c: number) => c > 0).length,
|
||||
sstableCount: this.lsm.getStats().sstableCount,
|
||||
memtableSize: this.lsm.getStats().memtableSize,
|
||||
indexDepth,
|
||||
sstableCount,
|
||||
memtableSize,
|
||||
estimatedMemory: this.lsm.getEstimatedMemory(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user