Sfoglia il codice sorgente

fix(core): 数据库/sql 驱动联调抓获两个引擎级 bug — 主键行存储键源 + 事务起始边界

1) 主键行存储树键错位: 插入/删除/更新统一用 extractIndexKey (取主键镜像列的
   【存储字节】) 当 B+ 树键, Uint64 镜像因'存储字节==BE'侥幸正确, Int64 等
   带 sort-key 编码的类型则错位 —— 行被挂到错键: 按 pkID 查找退化为全叶扫描,
   删除/更新按错键静默失效 (ErrNotFound 按索引逐个吞掉, 不报错也不动数据)。
   修复: 主键行存储的键恒为 pkID(BE) (rowStoreInsertKey), 与时序索引的
   '时间分量'键源区分; 插/删/改三侧同一键源。
   顺带: 行存储索引的分量列在建索引时拒绝可空声明 (定位键必须非空)。

2) 事务起始边界撕裂: 自动提交挂起的未落盘修改存在时 BEGIN → 事务内写入 →
   ROLLBACK, 回滚按 touched 丢页会把这些页里【事务开始前】的未提交修改一起
   丢掉, 未被 touched 的 IndexMeta 脏页仍指向被丢弃的树页 —— 'meta 指树、
   树已没了', 之后按索引读写全部 load failed。修复: BeginTransaction 先把
   未提交脏页提交掉 (BEGIN = 此前工作已提交的边界, 与 SQL BEGIN 语义一致;
   干净时是 no-op)。

3) core 补 NullCondition/IsNull/IsNotNull WHERE 谓词 —— 消费既有行内 NULL
   位图 (与反序列化/索引提取同一口径), 非空列写 NULL 仍由 serializeRow 拒绝。

新增回归: TestInt64PKDeleteUpdate / TestBeginTransactionConvergesPendingWrites。
yl32940 3 settimane fa
parent
commit
fca92ea663

+ 56 - 0
DB/core/condition_types.go

@@ -285,3 +285,59 @@ func (c OrCondition) Match(row []byte, table *Table) bool {
 	}
 	return false
 }
+
+// =============================================================================
+// NULL 判定条件
+// =============================================================================
+
+// NullCondition "列 IS [NOT] NULL" 条件 —— 消费引擎已有的 NULL 位图
+// (types.RowIsNull, 与 deserializeRow / extractIndexKey 同一口径), 不引入
+// 任何新的 NULL 语义。
+//
+// 为什么 Eq(列, nil) 表达不了: 条件求值走存储字节比较, NULL 列没有值字节,
+// CmpCondition 对 NULL 行一律不匹配 (与 SQL 的 "NULL 比较不为真" 一致)。
+// IS NULL 只能直接读位图判定。
+type NullCondition struct {
+	Field string
+	Neg   bool // true = IS NOT NULL
+}
+
+func (c NullCondition) Fields() []string { return []string{c.Field} }
+
+func (c NullCondition) Match(row []byte, table *Table) bool {
+	fieldIdx := table.findFieldIndex(c.Field)
+	if fieldIdx < 0 || len(row) < 1 {
+		return false
+	}
+	layout, ok := table.parseRowLayout(row)
+	if !ok {
+		return false
+	}
+	// 字段在该行 schema 版本里的序号 (版本不含此列 = 旧行, 该列尚不存在,
+	// 按非 NULL 处理 —— IS NOT NULL 命中, IS NULL 不命中)
+	pos := -1
+	for i, idx := range layout.sv.FieldIndices {
+		if int(idx) == fieldIdx {
+			pos = i
+			break
+		}
+	}
+	if pos < 0 {
+		return c.Neg
+	}
+	isNull := types.RowIsNull(row, layout.ends, len(layout.sv.FieldIndices), pos)
+	if c.Neg {
+		return !isNull
+	}
+	return isNull
+}
+
+// IsNull 构造 "field IS NULL" 条件。
+func IsNull(field string) Condition {
+	return NullCondition{Field: field}
+}
+
+// IsNotNull 构造 "field IS NOT NULL" 条件。
+func IsNotNull(field string) Condition {
+	return NullCondition{Field: field, Neg: true}
+}

+ 44 - 2
DB/core/index_meta_page.go

@@ -550,6 +550,26 @@ func (im *IndexMetaPage) saveMeta(m *IndexMeta) {
 	}
 }
 
+// rowStoreInsertKey 行存储索引的定位键:
+//
+//   - 主键 B+ 树: 恒为 pkID(BE)。查询/删除/更新侧一律按 pkID 定位, 与镜像列
+//     的存储编码无关 —— 旧实现统一走 extractIndexKey (取镜像列字节), 镜像列是
+//     Int64 等带 sort-key 编码的类型时, 树键与 pkID-BE 不一致: 按 pkID 查找
+//     退化成全叶扫描, 删除/更新按错键静默失效 (ErrNotFound 按索引逐个吞掉)。
+//     Uint64 镜像因"存储字节==BE"而侥幸正确, 掩盖了这条错位。
+//   - 时序索引: 键 = 时间分量 (extractIndexKey); 其顺序断言/分段路由都在同一
+//     编码内自洽 (sort-key 保序), 维持原样。
+//
+// 插入/删除/更新三侧必须用同一键源, 否则写进树里的键和去树里找的键对不上。
+func rowStoreInsertKey(meta *IndexMeta, pkID uint64, rowData []byte, fields []types.FieldInfo, versions []SchemaVersion) []byte {
+	if meta.IndexType == page.IndexTypePrimary {
+		key := make([]byte, 8)
+		binary.BigEndian.PutUint64(key, pkID)
+		return key
+	}
+	return extractIndexKey(rowData, meta, fields, versions)
+}
+
 // extractIndexKey 从行数据中提取索引键 (版本感知)
 // rowData 格式 (v9): [schemaVer 1B][列0 W0 B][列结束偏移表 2N][列1..N-1 内容][NULL 位图]
 //
@@ -655,6 +675,9 @@ func (im *IndexMetaPage) InsertRow(fields []types.FieldInfo, pkID uint64, rowDat
 	for _, ri := range indexes {
 		m := ri.meta
 		indexKey := extractIndexKey(rowData, m, fields, versions)
+		if indexStoresRows(m) {
+			indexKey = rowStoreInsertKey(m, pkID, rowData, fields, versions)
+		}
 		if indexKey == nil {
 			continue
 		}
@@ -730,9 +753,15 @@ func (im *IndexMetaPage) InsertRows(fields []types.FieldInfo, pkIDs []uint64, ro
 		if bi, ok := ri.handler.(index.BatchInserter); ok {
 			rows := make([]index.BatchRow, len(rowDatas))
 			for i := range rowDatas {
+				var ik []byte
+				if indexStoresRows(m) {
+					ik = rowStoreInsertKey(m, pkIDs[i], rowDatas[i], fields, versions)
+				} else {
+					ik = extractIndexKey(rowDatas[i], m, fields, versions)
+				}
 				rows[i] = index.BatchRow{
 					PKID:     pkIDs[i],
-					IndexKey: extractIndexKey(rowDatas[i], m, fields, versions),
+					IndexKey: ik,
 					RowData:  rowDatas[i],
 				}
 			}
@@ -740,6 +769,9 @@ func (im *IndexMetaPage) InsertRows(fields []types.FieldInfo, pkIDs []uint64, ro
 		} else {
 			for i := range rowDatas {
 				indexKey := extractIndexKey(rowDatas[i], m, fields, versions)
+				if indexStoresRows(m) {
+					indexKey = rowStoreInsertKey(m, pkIDs[i], rowDatas[i], fields, versions)
+				}
 				if indexKey == nil {
 					continue
 				}
@@ -986,6 +1018,9 @@ func (im *IndexMetaPage) DeleteByKey(idxName string, indexKey []byte, storeRow [
 	for _, ri := range im.resolveIndexes() {
 		m := ri.meta
 		key := extractIndexKey(rowData, m, fields, versions)
+		if indexStoresRows(m) {
+			key = rowStoreInsertKey(m, pkID, rowData, fields, versions)
+		}
 		if len(key) == 0 {
 			if m != meta {
 				continue // 取不到行数据, 无法推导该索引键, 跳过
@@ -1043,11 +1078,18 @@ func (im *IndexMetaPage) UpdateByKey(indexKey []byte, newRowData []byte, pkID ui
 		oldKey := indexKey
 		oldKeyFromRow := false
 		if oldRowData != nil {
-			if k := extractIndexKey(oldRowData, m, fields, versions); len(k) > 0 {
+			if indexStoresRows(m) {
+				if k := rowStoreInsertKey(m, pkID, oldRowData, fields, versions); len(k) > 0 {
+					oldKey, oldKeyFromRow = k, true
+				}
+			} else if k := extractIndexKey(oldRowData, m, fields, versions); len(k) > 0 {
 				oldKey, oldKeyFromRow = k, true
 			}
 		}
 		newKey := extractIndexKey(newRowData, m, fields, versions)
+		if indexStoresRows(m) {
+			newKey = rowStoreInsertKey(m, pkID, newRowData, fields, versions)
+		}
 
 		entryPage := m.EntryPage
 		var err error

+ 108 - 0
DB/core/pk_int64_test.go

@@ -0,0 +1,108 @@
+package core
+
+// Int64 (及更窄整型) 主键镜像列的回归测试。
+//
+// 背景: 主键 B+ 树的键必须是 pkID(BE) —— 查询/删除/更新侧都按 pkID 定位。
+// 旧实现插入/删除/更新统一用 extractIndexKey (取镜像列的【存储字节】) 当树键:
+// Uint64 镜像"存储字节 == BE"侥幸正确, 而 Int64 等带 sort-key 编码的类型
+// 存储字节 ≠ pkID-BE —— 插入把行挂到错键上, 按 pkID 查找退化成全叶扫描,
+// 删除/更新按错键静默失效 (ErrNotFound 按索引逐个吞掉, 不报错也不动数据)。
+// 修复: 主键行存储的键恒为 pkID(BE) (rowStoreInsertKey), 与时序索引的
+// "时间分量"键源区分; 三侧 (插/删/改) 同一键源。
+
+import (
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+func int64PKTable(t *testing.T, db *DB, name string) *Table {
+	t.Helper()
+	tbl, err := db.CreateTable(name, []types.FieldInfo{
+		{FieldType: types.FieldType_Int64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("v")},
+	})
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建索引: %v", err)
+	}
+	return tbl
+}
+
+func TestInt64PKDeleteUpdate(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := int64PKTable(t, db, "t")
+
+	// 单行 + 批量两条路径都覆盖
+	tbl.InsertRowWithID(1, []types.FieldValue{
+		{FieldName: []byte("id"), Value: int64(1)},
+		{FieldName: []byte("v"), Value: int64(10)},
+	})
+	if _, err := tbl.InsertRowsWithIDs([]uint64{2, 3}, [][]types.FieldValue{
+		{{FieldName: []byte("id"), Value: int64(2)}, {FieldName: []byte("v"), Value: int64(20)}},
+		{{FieldName: []byte("id"), Value: int64(3)}, {FieldName: []byte("v"), Value: int64(30)}},
+	}); err != nil {
+		t.Fatalf("批量插入: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatal(err)
+	}
+
+	// 按 pkID 删除: 必须真正消失 (修复前: 静默失效)
+	if err := tbl.DeleteRowByPK(2); err != nil {
+		t.Fatalf("delete: %v", err)
+	}
+	if _, err := tbl.FindByPK(2); err == nil {
+		t.Fatalf("pkID=2 删除后仍可见 — Int64 主键删除静默失效")
+	}
+	if _, err := tbl.FindByPK(1); err != nil {
+		t.Fatalf("pkID=1 被误删: %v", err)
+	}
+	if got := tbl.TotalRows(); got != 2 {
+		t.Fatalf("TotalRows=%d, 想要 2", got)
+	}
+
+	// 更新: 修复前同样按错键静默失效
+	if err := tbl.UpdateRowByPK(1, []types.FieldValue{
+		{FieldName: []byte("v"), Value: int64(99)},
+	}); err != nil {
+		t.Fatalf("update: %v", err)
+	}
+	vals, err := tbl.FindByPK(1)
+	if err != nil {
+		t.Fatalf("find: %v", err)
+	}
+	var v int64
+	for _, fv := range vals {
+		if string(fv.FieldName) == "v" {
+			v, _ = fv.Value.(int64)
+		}
+	}
+	if v != 99 {
+		t.Fatalf("更新未生效: v=%d, 想要 99", v)
+	}
+
+	// 二级索引不受键源修复影响 (仍按分量列字节编键, 插入/查询同侧一致)
+	if _, err := tbl.CreateIndex("idx_v", page.IndexTypeBTree, []byte{1}); err != nil {
+		t.Fatalf("建二级索引: %v", err)
+	}
+	if _, err := tbl.InsertRowWithID(4, []types.FieldValue{
+		{FieldName: []byte("id"), Value: int64(4)},
+		{FieldName: []byte("v"), Value: int64(99)}, // 与 id=1 同 v, 非唯一索引允许
+	}); err != nil {
+		t.Fatalf("insert 4: %v", err)
+	}
+	n := 0
+	if err := tbl.Query().Where(Eq("v", int64(99), types.FieldType(0))).Scan(func(row []types.FieldValue) bool {
+		n++
+		return true
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if n != 2 {
+		t.Fatalf("二级索引应命中 2 行 (v=99), 实际 %d", n)
+	}
+}

+ 13 - 0
DB/core/table_index.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"slices"
 
+	"git.ycl.pub/yl32940/TsDB2/DB/core/index"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
@@ -47,6 +48,18 @@ func (table *Table) CreateIndex(idxName string, typ page.IndexType, fields []byt
 			return nil, fmt.Errorf("%w (索引 %q 的第 %d 个分量)", ErrIndexBlobField, idxName, i+1)
 		}
 	}
+	// 行存储索引 (主键/时序) 的分量列不允许可空: 它是行的定位键, 写入路径按它
+	// 提取索引键 (extractIndexKey), NULL 分量不提取 —— 键提取不出来时删除/更新
+	// 对目标索引静默失效 (ErrNotFound 按索引逐个吞掉, 不报错也不动数据)。
+	// 建索引这一步就拦下, 比"数据写进去之后删不掉"好查得多。
+	if h, ok := index.Compose.GetHandler(typ); ok && handlerStoresRows(h) {
+		for _, fid := range fields {
+			if int(fid) < len(table.Field) && table.Field[fid].IsNullable() {
+				return nil, fmt.Errorf("行存储索引 %q 的分量列 %q 声明了可空 (定位键必须非空, 请去掉 nullable 标记)",
+					idxName, table.Field[fid].FieldName)
+			}
+		}
+	}
 	// 溢出闸门: 分量列一旦建索引就不能再值级溢出, 行内下界随之抬升。
 	// 下界超过"一页两行"的配额时必须在这里拒绝 —— 否则这张表从建索引成功起就再也压不
 	// 进两行 (单条插入仍会"成功"地落在 8159..16332 之间, 页内顺序/有序插入/页内重建

+ 12 - 1
DB/core/transaction.go

@@ -41,7 +41,14 @@ func (txn *Transaction) undoAllocated(pageID uint32) {
 	}
 }
 
-// BeginTransaction 开启新事务
+// BeginTransaction 开启新事务。
+//
+// ⭐ 起始边界收敛: 先把自动提交尚未落盘的脏页提交掉, 再挂牌事务。否则事务回滚
+// 按 touched 丢弃页缓存时, 会连同这些页里【事务开始前】的未提交修改一起丢掉,
+// 而未被 touched 的元数据页 (IndexMeta 等) 仍指向被丢弃的树页 —— "meta 指树、
+// 树已没了" 的撕裂状态, 之后一切按索引的读写全部 load failed / 走空。
+// (驱动变异测试实锤: 自动提交挂起的插入 → BEGIN → 插入 → ROLLBACK → 再插入必
+// 炸; BEGIN 即"此前工作已提交"的边界, 与 SQL 的 BEGIN 语义一致。)
 func (db *DB) BeginTransaction() *Transaction {
 	if db.errIfReadOnly() != nil {
 		panic(ErrReadOnly) // 事务无 error 返回, 抢救模式下显式拒绝
@@ -52,6 +59,10 @@ func (db *DB) BeginTransaction() *Transaction {
 		// error 返回, 与上方抢救模式的处理一致: 显式 panic 拒绝嵌套/并发事务。
 		panic("BeginTransaction: 已有活跃事务, 禁止嵌套/并发事务 (请先 Commit 或 Rollback)")
 	}
+	// 边界收敛: 未提交的脏页先落盘 (有脏页才发生 fsync, 干净时是 no-op)
+	if err := db.Commit(); err != nil {
+		panic(fmt.Sprintf("BeginTransaction: 收敛未提交修改失败: %v", err))
+	}
 
 	// 操作级串行锁: 从这里持有, 直到 Commit/Rollback 成功释放。事务期间其他 goroutine
 	// 的读写一律在 accMu 上排队, 保证事务不被并发打断 / 读者不撞中间态。

+ 71 - 0
DB/core/txn_begin_boundary_test.go

@@ -0,0 +1,71 @@
+package core
+
+// 事务起始边界收敛的回归测试。
+//
+// 背景 (database/sql 驱动变异测试实锤): 自动提交挂起的未落盘插入 → BEGIN →
+// 事务内插入 → ROLLBACK → 再插入 —— "load failed"。根因: 回滚按 touched 丢弃
+// 页缓存时, 把这些页里【事务开始前】的未提交修改一起丢掉了, 而未被 touched 的
+// IndexMeta 页 (仍缓存着脏副本) 指向已被丢弃的树页 —— meta 指树、树已没了。
+// 修复: BeginTransaction 先把未提交脏页提交掉 (BEGIN = 此前工作已提交的边界)。
+
+import (
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+func TestBeginTransactionConvergesPendingWrites(t *testing.T) {
+	// 与驱动同款环境: 自动提交默认开 (1s 防抖), 不手动收敛
+	db, err := Open(t.TempDir() + "/begin_boundary.db")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Close()
+
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Int64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("v")},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatal(err)
+	}
+	// 自动提交挂起的插入 (不落盘)
+	if _, err := tbl.InsertRowWithID(1, []types.FieldValue{
+		{FieldName: []byte("id"), Value: int64(1)},
+		{FieldName: []byte("v"), Value: int64(10)},
+	}); err != nil {
+		t.Fatal(err)
+	}
+	// BEGIN (收敛未提交修改) → 事务内插入 → ROLLBACK
+	tx := db.BeginTransaction()
+	if _, err := tbl.InsertRowWithID(2, []types.FieldValue{
+		{FieldName: []byte("id"), Value: int64(2)},
+		{FieldName: []byte("v"), Value: int64(20)},
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tx.Rollback(); err != nil {
+		t.Fatal(err)
+	}
+	// 事务前的行必须还在 (Begin 时已收敛), 事务内的行必须没了
+	if _, err := tbl.FindByPK(1); err != nil {
+		t.Fatalf("事务前的行丢失: %v", err)
+	}
+	if _, err := tbl.FindByPK(2); err == nil {
+		t.Fatalf("事务内的行未被回滚")
+	}
+	// 修复前的炸点: 回滚后再插入必须成功
+	if _, err := tbl.InsertRowWithID(3, []types.FieldValue{
+		{FieldName: []byte("id"), Value: int64(3)},
+		{FieldName: []byte("v"), Value: int64(30)},
+	}); err != nil {
+		t.Fatalf("回滚后再插入: %v", err)
+	}
+	if _, err := tbl.FindByPK(3); err != nil {
+		t.Fatalf("新插入不可见: %v", err)
+	}
+}