Browse Source

add transactional durable storage

Danilo Fragoso 2 ngày trước cách đây
mục cha
commit
104aefa9ee

+ 1 - 0
.gitignore

@@ -3,4 +3,5 @@
 testdata
 .db
 *.log
+/pizzasql_*
 # runtime state lives in /tmp/pizzasql/runtime.json — no local files to ignore

+ 5 - 0
PKBFI_STORAGE_MIGRATION.md

@@ -31,6 +31,11 @@ The migration command exits after writing and verifying the destination. It refu
 - Existing PizzaSQL row values remain readable as legacy JSON after migration. New and updated rows use the versioned binary tuple encoding, so an eager row rewrite is unnecessary.
 - Schema and catalog values remain JSON because they are cold metadata.
 - Successful PKBFI writes are acknowledged only after PizzaKV's durability sync completes.
+- A `.pkvdb` file must have one PizzaSQL process owner. Optimistic scan and
+  predicate conflict detection uses process-local generations and is not safe
+  when multiple PizzaSQL processes share one storage file.
+- Read-only transactions validate scan and indexed-predicate generations, but
+  point reads are not revalidated unless the transaction also writes.
 
 ## Rollback
 

+ 93 - 163
pkg/executor/executor.go

@@ -20,6 +20,7 @@ import (
 type Executor struct {
 	schema   *storage.SchemaManager
 	table    *storage.TableManager
+	session  *storage.Session
 	analyzer *analyzer.Analyzer
 	catalog  *analyzer.Catalog
 	// Last SchemaManager version reflected in catalog.
@@ -32,8 +33,7 @@ type Executor struct {
 	// Transaction state
 	inTransaction      bool
 	savepoints         []string // stack of savepoint names
-	savepointPositions []int
-	txLog              []txLogEntry // transaction log for rollback
+	savepointPositions []int    // session mutation-log positions for each savepoint
 
 	// Subquery context for correlated subqueries
 	outerRow storage.Row
@@ -69,20 +69,13 @@ type DatabaseConnection struct {
 	Table  *storage.TableManager
 }
 
-// txLogEntry represents a transaction log entry for rollback support.
-type txLogEntry struct {
-	operation string // "INSERT", "UPDATE", "DELETE"
-	table     string
-	key       string
-	oldData   storage.Row // for UPDATE/DELETE, the original row data
-}
-
 // New creates a new executor.
 func New(schema *storage.SchemaManager, table *storage.TableManager) *Executor {
 	catalog := analyzer.NewCatalog()
 	executor := &Executor{
 		schema:            schema,
 		table:             table,
+		session:           storage.NewSession(schema, table),
 		analyzer:          analyzer.New(catalog),
 		catalog:           catalog,
 		attachedDatabases: make(map[string]*DatabaseConnection),
@@ -101,6 +94,16 @@ func New(schema *storage.SchemaManager, table *storage.TableManager) *Executor {
 	return executor
 }
 
+// NewSessionExecutor returns a new executor sharing the same schema and table
+// managers but with fresh per-executor state (transaction, subquery caches,
+// views). It is used to isolate concurrent requests that must not share mutable
+// executor state.
+func (e *Executor) NewSessionExecutor() *Executor {
+	exec := New(e.schema, e.table)
+	exec.SyncCatalog()
+	return exec
+}
+
 // SyncCatalog synchronizes the analyzer catalog with the storage schema.
 func (e *Executor) SyncCatalog() error {
 	tables, err := e.schema.ListTables()
@@ -323,7 +326,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 	// shape with no filters/grouping/distinct/join. Any unsupported shape falls
 	// through to the normal scan path.
 	if isCountStarSingleTable(stmt) {
-		count, err := e.table.CountFast(tableName)
+		count, err := e.session.CountFast(tableName)
 		if err != nil {
 			return nil, err
 		}
@@ -402,7 +405,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		colName, colValue, isEquality := e.extractIndexableCondition(stmt.Where)
 		if isEquality {
 			if strings.EqualFold(schema.PrimaryKey, colName) {
-				row, getErr := e.table.GetByPK(tableName, fmt.Sprintf("%v", colValue))
+				row, getErr := e.session.GetByPK(tableName, fmt.Sprintf("%v", colValue))
 				if getErr == nil {
 					normalizeRowBySchema(row, schema)
 					rows = []storage.Row{row}
@@ -418,7 +421,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				indexes, _ := e.schema.ListTableIndexes(tableName)
 				for _, idx := range indexes {
 					if len(idx.Columns) == 1 && strings.EqualFold(idx.Columns[0].Name, colName) {
-						rows, err = e.table.SelectByIndex(tableName, idx.Name, colValue)
+						rows, err = e.session.SelectByIndex(tableName, idx.Name, colValue)
 						if err == nil {
 							usedIndex = true
 							for i := range rows {
@@ -446,7 +449,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				return toBool(val)
 			}
 		}
-		rows, err = e.table.Select(tableName, filter)
+		rows, err = e.session.Select(tableName, filter)
 		if filterErr != nil {
 			return nil, filterErr
 		}
@@ -499,7 +502,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		}
 		// Cross-join with any remaining comma-separated FROM entries (mixed JOIN+comma syntax)
 		for _, tref := range stmt.From[1:] {
-			rightRows, rerr := e.table.Select(tref.Name, nil)
+			rightRows, rerr := e.session.Select(tref.Name, nil)
 			if rerr != nil {
 				return nil, rerr
 			}
@@ -794,7 +797,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 			}
 
 			// Load seed.
-			seedRows, rerr := e.table.Select(stmt.From[seed].Name, nil)
+			seedRows, rerr := e.session.Select(stmt.From[seed].Name, nil)
 			if rerr != nil {
 				return nil, rerr
 			}
@@ -836,7 +839,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 					}
 				}
 
-				nextRows, rerr := e.table.Select(stmt.From[nextC].Name, nil)
+				nextRows, rerr := e.session.Select(stmt.From[nextC].Name, nil)
 				if rerr != nil {
 					return nil, rerr
 				}
@@ -921,7 +924,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		if len(orderNonPJ) > 0 {
 			first := orderNonPJ[0]
 			if first != 0 {
-				rows, err = e.table.Select(stmt.From[first].Name, nil)
+				rows, err = e.session.Select(stmt.From[first].Name, nil)
 				if err != nil {
 					return nil, err
 				}
@@ -947,7 +950,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 			// Join remaining non-pre-joined tables.
 			for _, idx := range orderNonPJ[1:] {
 				ti := allTableInfos[idx]
-				rightRows, rerr := e.table.Select(stmt.From[idx].Name, nil)
+				rightRows, rerr := e.session.Select(stmt.From[idx].Name, nil)
 				if rerr != nil {
 					return nil, rerr
 				}
@@ -1015,7 +1018,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 			join := tref.Join
 			for join != nil && join.Table != nil {
 				rightRef := join.Table
-				rightRows, rerr := e.table.Select(rightRef.Name, nil)
+				rightRows, rerr := e.session.Select(rightRef.Name, nil)
 				if rerr != nil {
 					return nil, rerr
 				}
@@ -1748,17 +1751,10 @@ func (e *Executor) executeJoinsWithMode(tableRef parser.TableRef, leftRows []sto
 		return leftRows, nil
 	}
 
-	// Get the right table name and its data
+	// Get the right table name. Equality joins against its primary key can probe
+	// only the referenced rows instead of scanning the whole table.
 	rightTableRef := tableRef.Join.Table
 	rightTable := rightTableRef.Name
-	rightRows, err := e.table.Select(rightTable, nil)
-	if err != nil {
-		return nil, err
-	}
-	rightSchema, _ := e.schema.GetSchema(rightTable)
-	for _, row := range rightRows {
-		normalizeRowBySchema(row, rightSchema)
-	}
 
 	// Perform the join between left and right
 	var result []storage.Row
@@ -1786,6 +1782,38 @@ func (e *Executor) executeJoinsWithMode(tableRef parser.TableRef, leftRows []sto
 		Condition: tableRef.Join.Condition,
 	}
 	leftKey, rightKey, canHash := extractEqualityJoinKeys(tableRef.Join.Condition, syntheticLeft, syntheticJoin)
+	rightSchema, err := e.schema.GetSchema(rightTable)
+	if err != nil {
+		return nil, err
+	}
+	var rightRows []storage.Row
+	if canHash && strings.EqualFold(rightSchema.PrimaryKey, rightKey) && len(leftRows) <= 256 {
+		seen := make(map[string]bool, len(leftRows))
+		for _, left := range leftRows {
+			key := joinKeyString(left, leftKey)
+			if key == "\x00" || seen[key] {
+				continue
+			}
+			seen[key] = true
+			row, getErr := e.session.GetByPK(rightTable, key)
+			if getErr == storage.ErrKeyNotFound {
+				continue
+			}
+			if getErr != nil {
+				return nil, getErr
+			}
+			normalizeRowBySchema(row, rightSchema)
+			rightRows = append(rightRows, row)
+		}
+	} else {
+		rightRows, err = e.session.Select(rightTable, nil)
+		if err != nil {
+			return nil, err
+		}
+		for _, row := range rightRows {
+			normalizeRowBySchema(row, rightSchema)
+		}
+	}
 
 	switch tableRef.Join.Type {
 	case parser.JoinInner:
@@ -1880,7 +1908,7 @@ func (e *Executor) executeJoin(tableRef parser.TableRef, leftRows []storage.Row)
 	}
 
 	rightTable := join.Table.Name
-	rightRows, err := e.table.Select(rightTable, nil)
+	rightRows, err := e.session.Select(rightTable, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -2167,14 +2195,13 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 		var count int
 		if e.inTransaction {
 			for _, row := range rows {
-				if err := e.table.Insert(tableName, row); err != nil {
+				if err := e.session.Insert(tableName, row); err != nil {
 					return nil, err
 				}
-				e.txLog = append(e.txLog, txLogEntry{operation: "INSERT", table: tableName, key: fmt.Sprintf("%v", row[schema.PrimaryKey])})
 				count++
 			}
 		} else {
-			count, err = e.table.InsertBulk(tableName, rows)
+			count, err = e.session.InsertBulk(tableName, rows)
 		}
 		if err != nil {
 			return nil, err
@@ -2212,7 +2239,7 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 			}
 		}
 
-		err := e.table.Insert(tableName, row)
+		err := e.session.Insert(tableName, row)
 		if err != nil {
 			if strings.Contains(err.Error(), "duplicate") && (stmt.ConflictDoNothing || len(stmt.ConflictUpdate) > 0) {
 				if stmt.ConflictDoNothing {
@@ -2222,13 +2249,7 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 					return nil, fmt.Errorf("ON CONFLICT target must include primary key %s", schema.PrimaryKey)
 				}
 				pkValue := row[schema.PrimaryKey]
-				var oldRows []storage.Row
-				if e.inTransaction {
-					oldRows, _ = e.table.Select(tableName, func(existing storage.Row) bool {
-						return fmt.Sprintf("%v", existing[schema.PrimaryKey]) == fmt.Sprintf("%v", pkValue)
-					})
-				}
-				updated, updateErr := e.table.UpdateFunc(tableName, func(existing storage.Row) (storage.Row, error) {
+				updated, updateErr := e.session.UpdateFunc(tableName, func(existing storage.Row) (storage.Row, error) {
 					context := e.addTableAlias(existing, tableName)
 					updates := make(storage.Row)
 					for _, assignment := range stmt.ConflictUpdate {
@@ -2248,9 +2269,6 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 				if updated != 1 {
 					return nil, fmt.Errorf("ON CONFLICT row disappeared during update")
 				}
-				if e.inTransaction && len(oldRows) == 1 {
-					e.txLog = append(e.txLog, txLogEntry{operation: "UPDATE", table: tableName, key: fmt.Sprintf("%v", pkValue), oldData: oldRows[0]})
-				}
 				count++
 				continue
 			}
@@ -2264,11 +2282,11 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 					// Delete existing row and insert new one
 					pkValue := row[schema.PrimaryKey]
 					if pkValue != nil {
-						e.table.Delete(tableName, func(r storage.Row) bool {
+						e.session.Delete(tableName, func(r storage.Row) bool {
 							return fmt.Sprintf("%v", r[schema.PrimaryKey]) == fmt.Sprintf("%v", pkValue)
 						})
 						// Try insert again
-						if err := e.table.Insert(tableName, row); err != nil {
+						if err := e.session.Insert(tableName, row); err != nil {
 							return nil, err
 						}
 					}
@@ -2284,9 +2302,6 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 				return nil, err
 			}
 		}
-		if e.inTransaction {
-			e.txLog = append(e.txLog, txLogEntry{operation: "INSERT", table: tableName, key: fmt.Sprintf("%v", row[schema.PrimaryKey])})
-		}
 		count++
 	}
 
@@ -2346,16 +2361,13 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 			}
 		}
 		if equality && strings.EqualFold(column, schema.PrimaryKey) && !updatesPrimaryKey {
-			oldRow, updated, err := e.table.UpdateByPK(tableName, fmt.Sprintf("%v", value), updateFn)
+			_, updated, err := e.session.UpdateByPK(tableName, fmt.Sprintf("%v", value), updateFn)
 			if err != nil {
 				return nil, err
 			}
 			count := 0
 			if updated {
 				count = 1
-				if e.inTransaction {
-					e.txLog = append(e.txLog, txLogEntry{operation: "UPDATE", table: tableName, key: fmt.Sprintf("%v", oldRow[schema.PrimaryKey]), oldData: oldRow})
-				}
 			}
 			result := NewResult("UPDATE")
 			result.SetRowCount(count)
@@ -2363,20 +2375,10 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 		}
 	}
 
-	var oldRows []storage.Row
-	if e.inTransaction {
-		oldRows, err = e.table.Select(tableName, filter)
-		if err != nil {
-			return nil, err
-		}
-	}
-	count, err := e.table.UpdateFunc(tableName, updateFn, filter)
+	count, err := e.session.UpdateFunc(tableName, updateFn, filter)
 	if err != nil {
 		return nil, err
 	}
-	for i := 0; e.inTransaction && i < count && i < len(oldRows); i++ {
-		e.txLog = append(e.txLog, txLogEntry{operation: "UPDATE", table: tableName, key: fmt.Sprintf("%v", oldRows[i][schema.PrimaryKey]), oldData: oldRows[i]})
-	}
 
 	result := NewResult("UPDATE")
 	result.SetRowCount(count)
@@ -2405,16 +2407,13 @@ func (e *Executor) executeDelete(stmt *parser.DeleteStmt) (*Result, error) {
 	if stmt.Where != nil {
 		column, value, equality := e.extractIndexableCondition(stmt.Where)
 		if equality && strings.EqualFold(column, schema.PrimaryKey) {
-			oldRow, deleted, err := e.table.DeleteByPK(tableName, fmt.Sprintf("%v", value))
+			_, deleted, err := e.session.DeleteByPK(tableName, fmt.Sprintf("%v", value))
 			if err != nil {
 				return nil, err
 			}
 			count := 0
 			if deleted {
 				count = 1
-				if e.inTransaction {
-					e.txLog = append(e.txLog, txLogEntry{operation: "DELETE", table: tableName, key: fmt.Sprintf("%v", oldRow[schema.PrimaryKey]), oldData: oldRow})
-				}
 			}
 			result := NewResult("DELETE")
 			result.SetRowCount(count)
@@ -2422,20 +2421,10 @@ func (e *Executor) executeDelete(stmt *parser.DeleteStmt) (*Result, error) {
 		}
 	}
 
-	var oldRows []storage.Row
-	if e.inTransaction {
-		oldRows, err = e.table.Select(tableName, filter)
-		if err != nil {
-			return nil, err
-		}
-	}
-	count, err := e.table.Delete(tableName, filter)
+	count, err := e.session.Delete(tableName, filter)
 	if err != nil {
 		return nil, err
 	}
-	for i := 0; e.inTransaction && i < count && i < len(oldRows); i++ {
-		e.txLog = append(e.txLog, txLogEntry{operation: "DELETE", table: tableName, key: fmt.Sprintf("%v", oldRows[i][schema.PrimaryKey]), oldData: oldRows[i]})
-	}
 
 	result := NewResult("DELETE")
 	result.SetRowCount(count)
@@ -2532,7 +2521,7 @@ func (e *Executor) executeDropTable(stmt *parser.DropTableStmt) (*Result, error)
 			for i, col := range idx.Columns {
 				columns[i] = col.Name
 			}
-			e.table.ClearIndex(idx.Name, tableRef.Name, columns)
+			e.session.ClearIndex(idx.Name, tableRef.Name, columns)
 			// Drop the index schema
 			e.schema.DropIndex(idx.Name)
 		}
@@ -2541,7 +2530,7 @@ func (e *Executor) executeDropTable(stmt *parser.DropTableStmt) (*Result, error)
 		if err := e.schema.DropTable(tableRef.Name); err != nil {
 			return nil, err
 		}
-		e.table.InvalidateCache(tableRef.Name)
+		e.session.InvalidateCache(tableRef.Name)
 
 	}
 	if err := e.SyncCatalog(); err != nil {
@@ -2606,7 +2595,7 @@ func (e *Executor) executeCreateIndex(stmt *parser.CreateIndexStmt) (*Result, er
 	for i, col := range stmt.Columns {
 		columns[i] = col.Name
 	}
-	if err := e.table.BuildIndex(stmt.Name, stmt.Table, columns); err != nil {
+	if err := e.session.BuildIndex(stmt.Name, stmt.Table, columns); err != nil {
 		// Rollback index creation on failure
 		e.schema.DropIndex(stmt.Name)
 		return nil, fmt.Errorf("failed to build index: %w", err)
@@ -2633,7 +2622,7 @@ func (e *Executor) executeDropIndex(stmt *parser.DropIndexStmt) (*Result, error)
 		for i, col := range index.Columns {
 			columns[i] = col.Name
 		}
-		e.table.ClearIndex(stmt.Name, index.Table, columns)
+		e.session.ClearIndex(stmt.Name, index.Table, columns)
 	}
 
 	if err := e.schema.DropIndex(stmt.Name); err != nil {
@@ -2800,8 +2789,8 @@ func (e *Executor) executeAlterTableRename(table string, action *parser.RenameTa
 	if err := e.schema.RenameTable(table, action.NewName); err != nil {
 		return nil, err
 	}
-	e.table.InvalidateCache(table)
-	e.table.InvalidateCache(action.NewName)
+	e.session.InvalidateCache(table)
+	e.session.InvalidateCache(action.NewName)
 
 	// Update catalog
 	e.SyncCatalog()
@@ -2831,11 +2820,12 @@ func (e *Executor) executeBegin(stmt *parser.BeginStmt) (*Result, error) {
 		return nil, fmt.Errorf("cannot start a transaction within a transaction")
 	}
 
+	if err := e.session.Begin(); err != nil {
+		return nil, err
+	}
 	e.inTransaction = true
 	e.savepoints = nil
 	e.savepointPositions = nil
-	e.txLog = nil
-	e.schema.BeginTransaction()
 
 	result := NewResult("BEGIN")
 	return result, nil
@@ -2847,12 +2837,16 @@ func (e *Executor) executeCommit(stmt *parser.CommitStmt) (*Result, error) {
 		return nil, fmt.Errorf("cannot commit: no transaction in progress")
 	}
 
-	// Clear transaction state
+	if err := e.session.Commit(); err != nil {
+		e.inTransaction = false
+		e.savepoints = nil
+		e.savepointPositions = nil
+		return nil, err
+	}
+
 	e.inTransaction = false
 	e.savepoints = nil
 	e.savepointPositions = nil
-	e.txLog = nil
-	e.schema.EndTransaction()
 
 	result := NewResult("COMMIT")
 	return result, nil
@@ -2869,26 +2863,12 @@ func (e *Executor) executeRollback(stmt *parser.RollbackStmt) (*Result, error) {
 		return e.rollbackToSavepoint(stmt.Savepoint)
 	}
 
-	// Full rollback - undo all operations in reverse order
-	var rollbackErr error
-	for i := len(e.txLog) - 1; i >= 0; i-- {
-		entry := e.txLog[i]
-		if err := e.undoOperation(entry); err != nil {
-			if rollbackErr == nil {
-				rollbackErr = err
-			}
-		}
+	if err := e.session.Rollback(); err != nil {
+		return nil, err
 	}
-
-	// Clear transaction state
 	e.inTransaction = false
 	e.savepoints = nil
 	e.savepointPositions = nil
-	e.txLog = nil
-	e.schema.EndTransaction()
-	if rollbackErr != nil {
-		return nil, fmt.Errorf("rollback failed: %w", rollbackErr)
-	}
 
 	result := NewResult("ROLLBACK")
 	return result, nil
@@ -2898,14 +2878,17 @@ func (e *Executor) executeRollback(stmt *parser.RollbackStmt) (*Result, error) {
 func (e *Executor) executeSavepoint(stmt *parser.SavepointStmt) (*Result, error) {
 	if !e.inTransaction {
 		// SQLite allows SAVEPOINT outside transaction (starts implicit transaction)
+		if err := e.session.Begin(); err != nil {
+			return nil, err
+		}
 		e.inTransaction = true
-		e.txLog = nil
-		e.schema.BeginTransaction()
+		e.savepoints = nil
+		e.savepointPositions = nil
 	}
 
 	// Add savepoint marker
 	e.savepoints = append(e.savepoints, stmt.Name)
-	e.savepointPositions = append(e.savepointPositions, len(e.txLog))
+	e.savepointPositions = append(e.savepointPositions, e.session.Snapshot())
 
 	result := NewResult("SAVEPOINT")
 	return result, nil
@@ -3018,15 +3001,8 @@ func (e *Executor) rollbackToSavepoint(name string) (*Result, error) {
 		return nil, fmt.Errorf("no such savepoint: %s", name)
 	}
 
-	// Undo operations in reverse order
 	logPosition := e.savepointPositions[savepointIdx]
-	for i := len(e.txLog) - 1; i >= logPosition; i-- {
-		entry := e.txLog[i]
-		if err := e.undoOperation(entry); err != nil {
-			continue
-		}
-	}
-	e.txLog = e.txLog[:logPosition]
+	e.session.RollbackTo(logPosition)
 
 	// Remove savepoints after the target
 	e.savepoints = e.savepoints[:savepointIdx+1]
@@ -3046,52 +3022,6 @@ func (e *Executor) RollbackActive() error {
 	return err
 }
 
-// undoOperation reverses a single operation.
-func (e *Executor) undoOperation(entry txLogEntry) error {
-	switch entry.operation {
-	case "INSERT":
-		// Delete the inserted row
-		_, err := e.table.Delete(entry.table, func(r storage.Row) bool {
-			// Match by primary key stored in entry.key
-			pk := e.getPrimaryKey(entry.table)
-			if pk == "" {
-				return false
-			}
-			return fmt.Sprintf("%v", r[pk]) == entry.key
-		})
-		return err
-
-	case "DELETE":
-		// Re-insert the deleted row
-		if entry.oldData != nil {
-			return e.table.Insert(entry.table, entry.oldData)
-		}
-
-	case "UPDATE":
-		// Restore the old data
-		if entry.oldData != nil {
-			pk := e.getPrimaryKey(entry.table)
-			if pk != "" {
-				// Delete current row and insert old data
-				e.table.Delete(entry.table, func(r storage.Row) bool {
-					return fmt.Sprintf("%v", r[pk]) == entry.key
-				})
-				return e.table.Insert(entry.table, entry.oldData)
-			}
-		}
-	}
-	return nil
-}
-
-// getPrimaryKey returns the primary key column name for a table.
-func (e *Executor) getPrimaryKey(tableName string) string {
-	schema, err := e.schema.GetSchema(tableName)
-	if err != nil {
-		return ""
-	}
-	return schema.PrimaryKey
-}
-
 // extractIndexableCondition extracts column name and value from a simple equality condition.
 // Returns (column, value, true) if the expression is column = literal.
 func (e *Executor) extractIndexableCondition(expr parser.Expr) (string, interface{}, bool) {

+ 243 - 0
pkg/executor/transaction_test.go

@@ -0,0 +1,243 @@
+package executor
+
+import (
+	"fmt"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/testkv"
+)
+
+func newTestDB(t *testing.T) (*storage.KVPool, *storage.SchemaManager, *storage.TableManager) {
+	t.Helper()
+	kv := testkv.New(t)
+	pool := kv.Pool(8)
+	t.Cleanup(func() { pool.Close() })
+	schema := storage.NewSchemaManager(pool, "testdb")
+	table := storage.NewTableManager(pool, schema, "testdb")
+	return pool, schema, table
+}
+
+func newExec(schema *storage.SchemaManager, table *storage.TableManager) *Executor {
+	e := New(schema, table)
+	e.SyncCatalog()
+	return e
+}
+
+func execMust(t *testing.T, e *Executor, sql string) *Result {
+	t.Helper()
+	res, err := execSQL(e, sql)
+	if err != nil {
+		t.Fatalf("exec %q: %v", sql, err)
+	}
+	return res
+}
+
+func TestSQLTransactionReadYourWrites(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'one')")
+	if res := execMust(t, e, "SELECT * FROM t WHERE id = 1"); res.RowCount != 1 {
+		t.Fatalf("expected 1 row before commit, got %d", res.RowCount)
+	}
+	execMust(t, e, "COMMIT")
+	if res := execMust(t, e, "SELECT * FROM t WHERE id = 1"); res.RowCount != 1 {
+		t.Fatalf("expected 1 row after commit, got %d", res.RowCount)
+	}
+}
+
+func TestSQLAutocommitDeleteWithSubquery(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE organizations (id INTEGER PRIMARY KEY, active INTEGER)")
+	execMust(t, e, "CREATE TABLE metrics (id INTEGER PRIMARY KEY, organization_id INTEGER)")
+	execMust(t, e, "INSERT INTO organizations VALUES (1, 0)")
+	execMust(t, e, "INSERT INTO organizations VALUES (2, 1)")
+	execMust(t, e, "INSERT INTO metrics VALUES (10, 1)")
+	execMust(t, e, "INSERT INTO metrics VALUES (20, 2)")
+
+	type outcome struct {
+		result *Result
+		err    error
+	}
+	done := make(chan outcome, 1)
+	go func() {
+		result, err := execSQL(e, "DELETE FROM metrics WHERE organization_id IN (SELECT id FROM organizations WHERE active = 0)")
+		done <- outcome{result: result, err: err}
+	}()
+
+	select {
+	case got := <-done:
+		if got.err != nil {
+			t.Fatal(got.err)
+		}
+		if got.result.RowsAffected != 1 {
+			t.Fatalf("deleted %d rows, want 1", got.result.RowsAffected)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("DELETE with subquery deadlocked")
+	}
+
+	if result := execMust(t, e, "SELECT id FROM metrics"); result.RowCount != 1 || result.Rows[0][0] != int64(20) {
+		t.Fatalf("remaining rows = %v", result.Rows)
+	}
+}
+
+func TestSQLTransactionRollbackZeroDurableWrites(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "INSERT INTO t VALUES (1)")
+	execMust(t, e, "INSERT INTO t VALUES (2)")
+	execMust(t, e, "ROLLBACK")
+	res := execMust(t, e, "SELECT COUNT(*) FROM t")
+	if len(res.Rows) != 1 || res.Rows[0][0] != int64(0) {
+		t.Fatalf("expected 0 rows after rollback, got %v", res.Rows)
+	}
+}
+
+func TestSQLTransactionSavepoints(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "INSERT INTO t VALUES (1)")
+	execMust(t, e, "SAVEPOINT sp1")
+	execMust(t, e, "INSERT INTO t VALUES (2)")
+	execMust(t, e, "ROLLBACK TO sp1")
+	execMust(t, e, "COMMIT")
+	if res := execMust(t, e, "SELECT COUNT(*) FROM t"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("expected 1 row after savepoint rollback, got %v", res.Rows[0][0])
+	}
+	if res := execMust(t, e, "SELECT * FROM t WHERE id = 2"); res.RowCount != 0 {
+		t.Fatalf("row 2 should be discarded")
+	}
+}
+
+func TestSQLTransactionConcurrentUpdate(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e1 := newExec(schema, table)
+	e2 := newExec(schema, table)
+
+	execMust(t, e1, "CREATE TABLE acct (id INTEGER PRIMARY KEY, bal INTEGER)")
+	execMust(t, e1, "INSERT INTO acct VALUES (1, 100)")
+
+	execMust(t, e1, "BEGIN")
+	execMust(t, e2, "BEGIN")
+	execMust(t, e1, "UPDATE acct SET bal = bal + 10 WHERE id = 1")
+	execMust(t, e2, "UPDATE acct SET bal = bal + 20 WHERE id = 1")
+
+	results := make(chan error, 2)
+	go func() { _, err := execSQL(e1, "COMMIT"); results <- err }()
+	go func() { _, err := execSQL(e2, "COMMIT"); results <- err }()
+
+	errs := [2]error{<-results, <-results}
+	ok, conflict := 0, 0
+	for _, err := range errs {
+		switch {
+		case err == nil:
+			ok++
+		case err == storage.ErrSerialization:
+			conflict++
+		default:
+			t.Fatalf("unexpected commit error: %v", err)
+		}
+	}
+	if ok != 1 || conflict != 1 {
+		t.Fatalf("expected exactly one commit and one conflict, got ok=%d conflict=%d", ok, conflict)
+	}
+	if res := execMust(t, e1, "SELECT bal FROM acct WHERE id = 1"); res.Rows[0][0] != int64(110) && res.Rows[0][0] != int64(120) {
+		t.Fatalf("balance should be 110 or 120 (the winning update), got %v", res.Rows[0][0])
+	}
+}
+
+func TestSQLAtomicMultiTableCommit(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE a (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE b (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "INSERT INTO a VALUES (1)")
+	execMust(t, e, "INSERT INTO b VALUES (1)")
+	execMust(t, e, "COMMIT")
+	if res := execMust(t, e, "SELECT COUNT(*) FROM a"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("a count = %v", res.Rows[0][0])
+	}
+	if res := execMust(t, e, "SELECT COUNT(*) FROM b"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("b count = %v", res.Rows[0][0])
+	}
+}
+
+func TestSQLTransactionJoinIgnoresUnrelatedRows(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e1 := newExec(schema, table)
+	e2 := newExec(schema, table)
+
+	execMust(t, e1, "CREATE TABLE products (id TEXT PRIMARY KEY, inventory INTEGER)")
+	execMust(t, e1, "CREATE TABLE cart_items (id TEXT PRIMARY KEY, cart_id TEXT, product_id TEXT)")
+	execMust(t, e1, "CREATE INDEX idx_cart_items_cart ON cart_items (cart_id)")
+	execMust(t, e1, "CREATE TABLE orders (id TEXT PRIMARY KEY)")
+	execMust(t, e1, "INSERT INTO products VALUES ('p1', 10)")
+	execMust(t, e1, "INSERT INTO products VALUES ('p2', 20)")
+	execMust(t, e1, "INSERT INTO cart_items VALUES ('i1', 'cart-a', 'p1')")
+
+	execMust(t, e1, "BEGIN")
+	if result := execMust(t, e1, "SELECT ci.id, p.inventory FROM cart_items ci JOIN products p ON ci.product_id = p.id WHERE ci.cart_id = 'cart-a'"); result.RowCount != 1 {
+		t.Fatalf("join returned %d rows", result.RowCount)
+	}
+	execMust(t, e2, "UPDATE products SET inventory = 21 WHERE id = 'p2'")
+	execMust(t, e2, "INSERT INTO cart_items VALUES ('i2', 'cart-b', 'p2')")
+	execMust(t, e1, "INSERT INTO orders VALUES ('o1')")
+	if _, err := execSQL(e1, "COMMIT"); err != nil {
+		t.Fatalf("unrelated product/cart item caused conflict: %v", err)
+	}
+}
+
+func TestSQLDuplicateInsert(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO t VALUES (1)")
+	if _, err := execSQL(e, "INSERT INTO t VALUES (1)"); err == nil {
+		t.Fatal("expected duplicate insert to fail")
+	}
+}
+
+func TestSQLPerTableConcurrentRowIDs(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (name TEXT)")
+
+	const n = 50
+	var wg sync.WaitGroup
+	errCh := make(chan error, n)
+	for i := 0; i < n; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			exec := newExec(schema, table)
+			if _, err := execSQL(exec, fmt.Sprintf("INSERT INTO t VALUES ('n%d')", i)); err != nil {
+				errCh <- fmt.Errorf("insert %d: %v", i, err)
+			}
+		}(i)
+	}
+	wg.Wait()
+	close(errCh)
+	for err := range errCh {
+		t.Fatal(err)
+	}
+
+	check := newExec(schema, table)
+	res, err := execSQL(check, "SELECT COUNT(*) FROM t")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if res.Rows[0][0] != int64(n) {
+		t.Fatalf("expected %d rows, got %v", n, res.Rows[0][0])
+	}
+}

+ 73 - 54
pkg/httpserver/handler.go

@@ -1,6 +1,7 @@
 package httpserver
 
 import (
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -12,18 +13,21 @@ import (
 
 	"github.com/danfragoso/pizzasql-next/pkg/csvexport"
 	"github.com/danfragoso/pizzasql-next/pkg/csvimport"
+	"github.com/danfragoso/pizzasql-next/pkg/executor"
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlexport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
 	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 
 // QueryRequest represents a single query request.
 type QueryRequest struct {
-	SQL    string        `json:"sql"`
-	Params []interface{} `json:"params"`
+	SQL           string        `json:"sql"`
+	Params        []interface{} `json:"params"`
+	TransactionID string        `json:"transactionId,omitempty"`
 }
 
 // ExecuteRequest represents a batch execution request.
@@ -55,12 +59,25 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// Get database from X-Database header
-	dbName := r.Header.Get("X-Database")
-	exec, _, err := s.getExecutorForDatabase(dbName)
-	if err != nil {
-		writeError(w, http.StatusBadRequest, "DATABASE_ERROR", fmt.Sprintf("database not found: %s", dbName), nil)
-		return
+	var tx *transactionExecutor
+	var exec *executor.Executor
+	if req.TransactionID != "" {
+		var ok bool
+		tx, ok = s.getTransactionExecutor(req.TransactionID, r.Header.Get("X-Database"))
+		if !ok {
+			writeError(w, http.StatusNotFound, "TRANSACTION_NOT_FOUND", "unknown transaction ID", nil)
+			return
+		}
+		exec = tx.exec
+	} else {
+		// Get database from X-Database header.
+		dbName := r.Header.Get("X-Database")
+		var err error
+		exec, _, err = s.getExecutorForDatabase(dbName)
+		if err != nil {
+			writeError(w, http.StatusBadRequest, "DATABASE_ERROR", fmt.Sprintf("database not found: %s", dbName), nil)
+			return
+		}
 	}
 
 	// Check for pretty print
@@ -81,6 +98,9 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
 	errorChan := make(chan error, 1)
 
 	go func() {
+		if tx != nil {
+			defer tx.mu.Unlock()
+		}
 		start := time.Now()
 
 		// Check readonly mode
@@ -221,7 +241,10 @@ func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) {
 		l := lexer.New("BEGIN")
 		p := parser.New(l)
 		stmt, _ := p.Parse()
-		exec.Execute(stmt)
+		if _, err := exec.Execute(stmt); err != nil {
+			writeError(w, http.StatusInternalServerError, "TRANSACTION_ERROR", err.Error(), nil)
+			return
+		}
 	}
 
 	var executeErr error
@@ -256,7 +279,10 @@ func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) {
 			l := lexer.New("ROLLBACK")
 			p := parser.New(l)
 			stmt, _ := p.Parse()
-			exec.Execute(stmt)
+			if _, rollbackErr := exec.Execute(stmt); rollbackErr != nil {
+				writeError(w, http.StatusInternalServerError, "ROLLBACK_ERROR", rollbackErr.Error(), nil)
+				return
+			}
 
 			writeError(w, http.StatusBadRequest, "TRANSACTION_ERROR", executeErr.Error(), nil)
 			return
@@ -265,7 +291,14 @@ func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) {
 			l := lexer.New("COMMIT")
 			p := parser.New(l)
 			stmt, _ := p.Parse()
-			exec.Execute(stmt)
+			if _, err := exec.Execute(stmt); err != nil {
+				if errors.Is(err, storage.ErrSerialization) {
+					writeError(w, http.StatusConflict, "SERIALIZATION_FAILURE", err.Error(), nil)
+				} else {
+					writeError(w, http.StatusBadRequest, "TRANSACTION_ERROR", err.Error(), nil)
+				}
+				return
+			}
 		}
 	} else if executeErr != nil {
 		writeError(w, http.StatusBadRequest, "EXECUTION_ERROR", executeErr.Error(), nil)
@@ -417,27 +450,15 @@ func (s *Server) handleTransactionBegin(w http.ResponseWriter, r *http.Request)
 
 	// Get database from X-Database header
 	dbName := r.Header.Get("X-Database")
-	exec, _, err := s.getExecutorForDatabase(dbName)
-	if err != nil {
-		writeError(w, http.StatusBadRequest, "DATABASE_ERROR", fmt.Sprintf("database not found: %s", dbName), nil)
-		return
-	}
-
-	l := lexer.New("BEGIN")
-	p := parser.New(l)
-	stmt, _ := p.Parse()
-	_, err = exec.Execute(stmt)
-
+	txID, _, err := s.beginTransaction(dbName)
 	if err != nil {
 		writeError(w, http.StatusInternalServerError, "TRANSACTION_ERROR", err.Error(), nil)
 		return
 	}
 
-	// Generate transaction ID (simple implementation)
-	txID := fmt.Sprintf("tx-%d", time.Now().UnixNano())
-
 	resp := map[string]interface{}{
 		"transactionId": txID,
+		"status":        "started",
 	}
 
 	pretty := r.URL.Query().Get("pretty") == "true"
@@ -451,26 +472,27 @@ func (s *Server) handleTransactionCommit(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	// Get database from X-Database header
-	dbName := r.Header.Get("X-Database")
-	exec, _, err := s.getExecutorForDatabase(dbName)
-	if err != nil {
-		writeError(w, http.StatusBadRequest, "DATABASE_ERROR", fmt.Sprintf("database not found: %s", dbName), nil)
-		return
-	}
-
 	var req TransactionRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		// Allow commit without transaction ID for simplicity
+		writeError(w, http.StatusBadRequest, "INVALID_JSON", "Invalid JSON in request body", nil)
+		return
+	}
+	tx, ok := s.takeTransactionExecutor(req.TransactionID, r.Header.Get("X-Database"))
+	if !ok {
+		writeError(w, http.StatusNotFound, "TRANSACTION_NOT_FOUND", "unknown transaction ID", nil)
+		return
 	}
+	defer tx.mu.Unlock()
 
 	l := lexer.New("COMMIT")
 	p := parser.New(l)
 	stmt, _ := p.Parse()
-	_, err = exec.Execute(stmt)
-
-	if err != nil {
-		writeError(w, http.StatusInternalServerError, "TRANSACTION_ERROR", err.Error(), nil)
+	if _, err := tx.exec.Execute(stmt); err != nil {
+		if errors.Is(err, storage.ErrSerialization) {
+			writeError(w, http.StatusConflict, "SERIALIZATION_FAILURE", err.Error(), nil)
+		} else {
+			writeError(w, http.StatusBadRequest, "TRANSACTION_ERROR", err.Error(), nil)
+		}
 		return
 	}
 
@@ -553,26 +575,23 @@ func (s *Server) handleTransactionRollback(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	// Get database from X-Database header
-	dbName := r.Header.Get("X-Database")
-	exec, _, err := s.getExecutorForDatabase(dbName)
-	if err != nil {
-		writeError(w, http.StatusBadRequest, "DATABASE_ERROR", fmt.Sprintf("database not found: %s", dbName), nil)
-		return
-	}
-
 	var req TransactionRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		// Allow rollback without transaction ID for simplicity
+		writeError(w, http.StatusBadRequest, "INVALID_JSON", "Invalid JSON in request body", nil)
+		return
+	}
+	tx, ok := s.takeTransactionExecutor(req.TransactionID, r.Header.Get("X-Database"))
+	if !ok {
+		writeError(w, http.StatusNotFound, "TRANSACTION_NOT_FOUND", "unknown transaction ID", nil)
+		return
 	}
+	defer tx.mu.Unlock()
 
 	l := lexer.New("ROLLBACK")
 	p := parser.New(l)
 	stmt, _ := p.Parse()
-	_, err = exec.Execute(stmt)
-
-	if err != nil {
-		writeError(w, http.StatusInternalServerError, "TRANSACTION_ERROR", err.Error(), nil)
+	if _, err := tx.exec.Execute(stmt); err != nil {
+		writeError(w, http.StatusBadRequest, "TRANSACTION_ERROR", err.Error(), nil)
 		return
 	}
 
@@ -890,9 +909,9 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
 		result, err := sqliteimport.ImportSQLiteBytes(fileContent, exec, opts)
 		if err != nil && !ignoreErrors {
 			writeError(w, http.StatusBadRequest, "IMPORT_ERROR", err.Error(), map[string]interface{}{
-				"tablesCreated":  result.TablesCreated,
-				"rowsInserted":   result.RowsInserted,
-				"errors":         result.Errors,
+				"tablesCreated": result.TablesCreated,
+				"rowsInserted":  result.RowsInserted,
+				"errors":        result.Errors,
 			})
 			return
 		}

+ 211 - 0
pkg/httpserver/isolation_test.go

@@ -0,0 +1,211 @@
+package httpserver
+
+import (
+	"bytes"
+	"net/http"
+	"net/http/httptest"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/goccy/go-json"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/testkv"
+)
+
+func newTestDBServer(t *testing.T) *Server {
+	t.Helper()
+	kv := testkv.New(t)
+	pool := kv.Pool(8)
+	t.Cleanup(func() { pool.Close() })
+
+	dm := storage.NewDatabaseManager(pool, &storage.DatabaseManagerConfig{
+		DefaultDatabase: "testdb",
+		AutoCreate:      true,
+	})
+	config := DefaultConfig()
+	config.EnableAuth = false
+	return NewWithDatabaseManager(config, dm)
+}
+
+func postQuery(t *testing.T, s *Server, sql string) *httptest.ResponseRecorder {
+	t.Helper()
+	body, _ := json.Marshal(QueryRequest{SQL: sql})
+	r := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
+	w := httptest.NewRecorder()
+	s.handleQuery(w, r)
+	return w
+}
+
+func postTransactionQuery(t *testing.T, s *Server, txID, sql string) *httptest.ResponseRecorder {
+	t.Helper()
+	body, _ := json.Marshal(QueryRequest{SQL: sql, TransactionID: txID})
+	r := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
+	w := httptest.NewRecorder()
+	s.handleQuery(w, r)
+	return w
+}
+
+func beginTransaction(t *testing.T, s *Server, database string) string {
+	t.Helper()
+	r := httptest.NewRequest(http.MethodPost, "/transaction/begin", nil)
+	r.Header.Set("X-Database", database)
+	w := httptest.NewRecorder()
+	s.handleTransactionBegin(w, r)
+	if w.Code != http.StatusOK {
+		t.Fatalf("begin status %d: %s", w.Code, w.Body.String())
+	}
+	var resp map[string]interface{}
+	if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+		t.Fatal(err)
+	}
+	return resp["transactionId"].(string)
+}
+
+func postExecute(t *testing.T, s *Server, req ExecuteRequest) *httptest.ResponseRecorder {
+	t.Helper()
+	body, _ := json.Marshal(req)
+	r := httptest.NewRequest(http.MethodPost, "/execute", bytes.NewReader(body))
+	w := httptest.NewRecorder()
+	s.handleExecute(w, r)
+	return w
+}
+
+// TestHTTPTransactionIsolation verifies that concurrent HTTP requests do not
+// share mutable executor state: two concurrent transactional batch executes
+// both commit their own rows without cross-talk.
+func TestHTTPTransactionIsolation(t *testing.T) {
+	s := newTestDBServer(t)
+	w := postQuery(t, s, "CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)")
+	if w.Code != http.StatusOK {
+		t.Fatalf("create table: %d %s", w.Code, w.Body.String())
+	}
+
+	const n = 20
+	var wg sync.WaitGroup
+	codes := make(chan int, n)
+	for i := 0; i < n; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			req := ExecuteRequest{
+				Transaction: true,
+				Statements: []QueryRequest{
+					{SQL: "INSERT INTO items VALUES (" + itoa(i) + ", 'v')"},
+				},
+			}
+			codes <- postExecute(t, s, req).Code
+		}(i)
+	}
+	wg.Wait()
+	close(codes)
+	for code := range codes {
+		if code != http.StatusOK {
+			t.Fatalf("execute transaction returned status %d", code)
+		}
+	}
+
+	res := postQuery(t, s, "SELECT COUNT(*) FROM items")
+	var resp QueryResponse
+	if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
+		t.Fatal(err)
+	}
+	if len(resp.Rows) != 1 || resp.Rows[0][0] != float64(n) {
+		t.Fatalf("expected %d rows, got %v", n, resp.Rows)
+	}
+}
+
+// TestHTTPTransactionEndpointsIsolation verifies that the session-based
+// BEGIN/COMMIT endpoints keep separate transactions isolated and single-use.
+func TestHTTPTransactionEndpointsIsolation(t *testing.T) {
+	s := newTestDBServer(t)
+	if w := postQuery(t, s, "CREATE TABLE t (id INTEGER PRIMARY KEY)"); w.Code != http.StatusOK {
+		t.Fatalf("create table: %d", w.Code)
+	}
+
+	begin := func() string {
+		r := httptest.NewRequest(http.MethodPost, "/transaction/begin", nil)
+		w := httptest.NewRecorder()
+		s.handleTransactionBegin(w, r)
+		if w.Code != http.StatusOK {
+			t.Fatalf("begin status %d", w.Code)
+		}
+		var resp map[string]interface{}
+		json.NewDecoder(w.Body).Decode(&resp)
+		return resp["transactionId"].(string)
+	}
+	commit := func(txID string) int {
+		body, _ := json.Marshal(TransactionRequest{TransactionID: txID})
+		r := httptest.NewRequest(http.MethodPost, "/transaction/commit", bytes.NewReader(body))
+		w := httptest.NewRecorder()
+		s.handleTransactionCommit(w, r)
+		return w.Code
+	}
+
+	tx1 := begin()
+	tx2 := begin()
+	if tx1 == tx2 {
+		t.Fatal("expected distinct transaction IDs")
+	}
+	if w := postTransactionQuery(t, s, tx1, "INSERT INTO t VALUES (1)"); w.Code != http.StatusOK {
+		t.Fatalf("tx1 insert status %d: %s", w.Code, w.Body.String())
+	}
+	if w := postTransactionQuery(t, s, tx2, "INSERT INTO t VALUES (2)"); w.Code != http.StatusOK {
+		t.Fatalf("tx2 insert status %d: %s", w.Code, w.Body.String())
+	}
+	if w := postQuery(t, s, "SELECT COUNT(*) FROM t"); w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte("[[0]]")) {
+		t.Fatalf("uncommitted rows became visible: %d %s", w.Code, w.Body.String())
+	}
+
+	// Committing one transaction must not affect the other.
+	if code := commit(tx1); code != http.StatusOK {
+		t.Fatalf("commit tx1 status %d", code)
+	}
+	// A second commit of the same transaction fails (single-use).
+	if code := commit(tx1); code != http.StatusNotFound {
+		t.Fatalf("expected single-use transaction, got status %d", code)
+	}
+	// The other transaction is still usable.
+	if code := commit(tx2); code != http.StatusOK {
+		t.Fatalf("commit tx2 status %d", code)
+	}
+}
+
+func TestHTTPTransactionIDsAreDatabaseScopedAndExpire(t *testing.T) {
+	s := newTestDBServer(t)
+	txID := beginTransaction(t, s, "tenant-a")
+
+	body, _ := json.Marshal(QueryRequest{SQL: "SELECT 1", TransactionID: txID})
+	r := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
+	r.Header.Set("X-Database", "tenant-b")
+	w := httptest.NewRecorder()
+	s.handleQuery(w, r)
+	if w.Code != http.StatusNotFound {
+		t.Fatalf("cross-database transaction returned %d, want 404", w.Code)
+	}
+
+	s.transactionExecutorsMu.Lock()
+	s.transactionExecutors[txID].expiresAt = time.Now().Add(-time.Second)
+	s.transactionExecutorsMu.Unlock()
+	body, _ = json.Marshal(QueryRequest{SQL: "SELECT 1", TransactionID: txID})
+	r = httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
+	r.Header.Set("X-Database", "tenant-a")
+	w = httptest.NewRecorder()
+	s.handleQuery(w, r)
+	if w.Code != http.StatusNotFound {
+		t.Fatalf("expired transaction returned %d, want 404", w.Code)
+	}
+}
+
+func itoa(i int) string {
+	if i == 0 {
+		return "0"
+	}
+	var b []byte
+	for i > 0 {
+		b = append([]byte{byte('0' + i%10)}, b...)
+		i /= 10
+	}
+	return string(b)
+}

+ 108 - 49
pkg/httpserver/server.go

@@ -2,16 +2,23 @@ package httpserver
 
 import (
 	"context"
+	"crypto/rand"
+	"encoding/hex"
 	"fmt"
 	"log"
 	"net/http"
+	"strings"
 	"sync"
 	"time"
 
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
 )
 
+const httpTransactionTTL = 30 * time.Minute
+
 // Config holds HTTP server configuration.
 type Config struct {
 	Host              string
@@ -47,15 +54,25 @@ func DefaultConfig() *Config {
 // Server represents the HTTP API server.
 type Server struct {
 	config    *Config
-	executor  *executor.Executor  // Default executor (for backward compatibility)
+	executor  *executor.Executor // Default executor (for backward compatibility)
 	schema    *storage.SchemaManager
 	dbManager *storage.DatabaseManager // Multi-database support
 	server    *http.Server
 	stats     *Stats
 
-	// Per-server executor cache for multi-database support
-	executorCache   map[string]*executor.Executor
-	executorCacheMu sync.RWMutex
+	// transactionExecutors holds a per-session executor for the HTTP
+	// transaction endpoints (BEGIN/COMMIT/ROLLBACK), keyed by transaction ID.
+	// This prevents transaction state and caches from being shared across
+	// concurrent HTTP requests.
+	transactionExecutorsMu sync.RWMutex
+	transactionExecutors   map[string]*transactionExecutor
+}
+
+type transactionExecutor struct {
+	mu        sync.Mutex
+	exec      *executor.Executor
+	database  string
+	expiresAt time.Time
 }
 
 // Stats tracks server statistics.
@@ -74,10 +91,10 @@ func New(config *Config, exec *executor.Executor, schema *storage.SchemaManager)
 	}
 
 	s := &Server{
-		config:        config,
-		executor:      exec,
-		schema:        schema,
-		executorCache: make(map[string]*executor.Executor),
+		config:               config,
+		executor:             exec,
+		schema:               schema,
+		transactionExecutors: make(map[string]*transactionExecutor),
 		stats: &Stats{
 			StartTime: time.Now(),
 		},
@@ -92,10 +109,6 @@ func NewWithDatabaseManager(config *Config, dbManager *storage.DatabaseManager)
 		config = DefaultConfig()
 	}
 
-	// Initialize executor cache
-	execCache := make(map[string]*executor.Executor)
-
-	// Get the default database for backward compatibility
 	defaultDB, _ := dbManager.GetDatabase("")
 	var defaultExec *executor.Executor
 	var defaultSchema *storage.SchemaManager
@@ -103,16 +116,14 @@ func NewWithDatabaseManager(config *Config, dbManager *storage.DatabaseManager)
 		defaultExec = executor.New(defaultDB.Schema, defaultDB.Table)
 		defaultExec.SyncCatalog()
 		defaultSchema = defaultDB.Schema
-		// Pre-populate cache with default executor
-		execCache[defaultDB.Name] = defaultExec
 	}
 
 	s := &Server{
-		config:        config,
-		executor:      defaultExec,
-		schema:        defaultSchema,
-		dbManager:     dbManager,
-		executorCache: execCache,
+		config:               config,
+		executor:             defaultExec,
+		schema:               defaultSchema,
+		dbManager:            dbManager,
+		transactionExecutors: make(map[string]*transactionExecutor),
 		stats: &Stats{
 			StartTime: time.Now(),
 		},
@@ -191,50 +202,98 @@ func (s *Server) Addr() string {
 	return s.server.Addr
 }
 
-// getExecutorForDatabase returns an executor for the specified database.
-// If dbName is empty, returns the default executor.
-// If multi-database support is not enabled, always returns the default executor.
+// getExecutorForDatabase returns a fresh executor for the specified database.
+// A new executor is created per call so transaction state, subquery caches, and
+// other mutable per-executor fields are never shared across concurrent HTTP
+// requests. If dbName is empty, the default database is used.
 func (s *Server) getExecutorForDatabase(dbName string) (*executor.Executor, *storage.SchemaManager, error) {
-	// If no database manager, use the default executor
+	// If no database manager, create a fresh executor from the default managers.
 	if s.dbManager == nil {
-		return s.executor, s.schema, nil
+		return s.executor.NewSessionExecutor(), s.schema, nil
 	}
 
-	// Get the database instance - this ensures we get the correct SchemaManager
 	dbInstance, err := s.dbManager.GetDatabase(dbName)
 	if err != nil {
 		return nil, nil, err
 	}
 
-	// IMPORTANT: Always use dbInstance.Schema for isolation
-	// The SchemaManager contains the database name and ensures queries
-	// are scoped to the correct database namespace
-
-	// Check per-server executor cache
-	s.executorCacheMu.RLock()
-	exec, exists := s.executorCache[dbInstance.Name]
-	s.executorCacheMu.RUnlock()
+	exec := executor.New(dbInstance.Schema, dbInstance.Table)
+	exec.SyncCatalog()
+	return exec, dbInstance.Schema, nil
+}
 
-	if exists {
-		// Return cached executor with the correct schema from dbInstance
-		return exec, dbInstance.Schema, nil
+// beginTransaction starts a transaction bound to a new session executor and
+// registers it under the returned transaction ID.
+func (s *Server) beginTransaction(dbName string) (string, *executor.Executor, error) {
+	dbName = strings.TrimSpace(dbName)
+	exec, _, err := s.getExecutorForDatabase(dbName)
+	if err != nil {
+		return "", nil, err
 	}
 
-	// Create new executor and cache it
-	s.executorCacheMu.Lock()
-	defer s.executorCacheMu.Unlock()
-
-	// Double-check after acquiring write lock
-	if exec, exists := s.executorCache[dbInstance.Name]; exists {
-		return exec, dbInstance.Schema, nil
+	l := lexer.New("BEGIN")
+	p := parser.New(l)
+	stmt, _ := p.Parse()
+	if _, err := exec.Execute(stmt); err != nil {
+		return "", nil, err
 	}
 
-	// Create executor with the database-specific schema and table managers
-	exec = executor.New(dbInstance.Schema, dbInstance.Table)
-	exec.SyncCatalog()
-	s.executorCache[dbInstance.Name] = exec
+	idBytes := make([]byte, 16)
+	if _, err := rand.Read(idBytes); err != nil {
+		return "", nil, fmt.Errorf("generate transaction ID: %w", err)
+	}
+	txID := "tx-" + hex.EncodeToString(idBytes)
+	now := time.Now()
+	s.transactionExecutorsMu.Lock()
+	for id, tx := range s.transactionExecutors {
+		if !tx.expiresAt.After(now) {
+			delete(s.transactionExecutors, id)
+		}
+	}
+	s.transactionExecutors[txID] = &transactionExecutor{
+		exec:      exec,
+		database:  dbName,
+		expiresAt: now.Add(httpTransactionTTL),
+	}
+	s.transactionExecutorsMu.Unlock()
+	return txID, exec, nil
+}
 
-	log.Printf("Created executor for database: %s", dbInstance.Name)
+func (s *Server) getTransactionExecutor(txID, dbName string) (*transactionExecutor, bool) {
+	dbName = strings.TrimSpace(dbName)
+	s.transactionExecutorsMu.Lock()
+	tx, ok := s.transactionExecutors[txID]
+	if ok && !tx.expiresAt.After(time.Now()) {
+		delete(s.transactionExecutors, txID)
+		ok = false
+	}
+	if ok && tx.database == dbName {
+		tx.mu.Lock()
+	} else {
+		ok = false
+	}
+	s.transactionExecutorsMu.Unlock()
+	return tx, ok
+}
 
-	return exec, dbInstance.Schema, nil
+// takeTransactionExecutor removes a session before COMMIT or ROLLBACK so it
+// cannot receive another request while its terminal command is running.
+func (s *Server) takeTransactionExecutor(txID, dbName string) (*transactionExecutor, bool) {
+	dbName = strings.TrimSpace(dbName)
+	s.transactionExecutorsMu.Lock()
+	tx, ok := s.transactionExecutors[txID]
+	if ok && !tx.expiresAt.After(time.Now()) {
+		delete(s.transactionExecutors, txID)
+		ok = false
+	}
+	if ok && tx.database == dbName {
+		delete(s.transactionExecutors, txID)
+	} else {
+		ok = false
+	}
+	s.transactionExecutorsMu.Unlock()
+	if ok {
+		tx.mu.Lock()
+	}
+	return tx, ok
 }

+ 13 - 4
pkg/pgserver/connection.go

@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"bytes"
 	"encoding/binary"
+	"errors"
 	"fmt"
 	"io"
 	"log"
@@ -317,10 +318,14 @@ func (c *Connection) handleQuery(msg *Message) error {
 		}
 		result, err := c.executor.Execute(stmt)
 		if err != nil {
-			if c.txStatus == TxStatusInBlock {
+			code := ErrCodeInternalError
+			if errors.Is(err, storage.ErrSerialization) {
+				code = ErrCodeSerializationFailure
+				c.txStatus = TxStatusIdle
+			} else if c.txStatus == TxStatusInBlock {
 				c.txStatus = TxStatusFailed
 			}
-			c.sendError("ERROR", ErrCodeInternalError, fmt.Sprintf("Execution error: %v", err))
+			c.sendError("ERROR", code, fmt.Sprintf("Execution error: %v", err))
 			return c.sendReadyForQuery()
 		}
 		if err := c.sendResult(result, stmt); err != nil {
@@ -482,10 +487,14 @@ func (c *Connection) handleExecute(msg *Message) error {
 	}
 	result, err := c.executor.Execute(stmt)
 	if err != nil {
-		if c.txStatus == TxStatusInBlock {
+		code := ErrCodeInternalError
+		if errors.Is(err, storage.ErrSerialization) {
+			code = ErrCodeSerializationFailure
+			c.txStatus = TxStatusIdle
+		} else if c.txStatus == TxStatusInBlock {
 			c.txStatus = TxStatusFailed
 		}
-		return c.failExtended(ErrCodeInternalError, fmt.Errorf("execution error: %w", err))
+		return c.failExtended(code, fmt.Errorf("execution error: %w", err))
 	}
 	return c.sendResult(result, stmt)
 }

+ 13 - 12
pkg/pgserver/protocol.go

@@ -79,18 +79,19 @@ const (
 
 // PostgreSQL error codes (subset)
 const (
-	ErrCodeSuccess             = "00000"
-	ErrCodeSyntaxError         = "42601"
-	ErrCodeUndefinedTable      = "42P01"
-	ErrCodeUndefinedColumn     = "42703"
-	ErrCodeDuplicateTable      = "42P07"
-	ErrCodeDuplicateColumn     = "42701"
-	ErrCodeInvalidParameter    = "22023"
-	ErrCodeInternalError       = "XX000"
-	ErrCodeConnectionFailure   = "08006"
-	ErrCodeProtocolViolation   = "08P01"
-	ErrCodeFeatureNotSupported = "0A000"
-	ErrCodeTransactionAborted  = "25P02"
+	ErrCodeSuccess              = "00000"
+	ErrCodeSyntaxError          = "42601"
+	ErrCodeUndefinedTable       = "42P01"
+	ErrCodeUndefinedColumn      = "42703"
+	ErrCodeDuplicateTable       = "42P07"
+	ErrCodeDuplicateColumn      = "42701"
+	ErrCodeInvalidParameter     = "22023"
+	ErrCodeInternalError        = "XX000"
+	ErrCodeConnectionFailure    = "08006"
+	ErrCodeProtocolViolation    = "08P01"
+	ErrCodeFeatureNotSupported  = "0A000"
+	ErrCodeTransactionAborted   = "25P02"
+	ErrCodeSerializationFailure = "40001"
 )
 
 // Message represents a PostgreSQL protocol message

+ 90 - 11
pkg/storage/kv.go

@@ -17,17 +17,18 @@ const (
 	headerMagic   = "PKBF"
 	headerVersion = 1
 
-	opPing       = 1
-	opStatus     = 2
-	opGet        = 3
-	opPut        = 4
-	opDelete     = 5
-	opExists     = 6
-	opMultiGet   = 7
-	opBatchWrite = 8
-	opScanOpen   = 9
-	opScanNext   = 10
-	opScanClose  = 11
+	opPing         = 1
+	opStatus       = 2
+	opGet          = 3
+	opPut          = 4
+	opDelete       = 5
+	opExists       = 6
+	opMultiGet     = 7
+	opBatchWrite   = 8
+	opScanOpen     = 9
+	opScanNext     = 10
+	opScanClose    = 11
+	opCompareBatch = 12
 
 	batchPut    = 1
 	batchDelete = 2
@@ -212,6 +213,11 @@ type BatchOp struct {
 	Value []byte
 }
 
+type CompareCheck struct {
+	Key []byte
+	LSN uint64
+}
+
 type ScanCursor struct {
 	client *KVClient
 	id     uint64
@@ -550,6 +556,79 @@ func (c *KVClient) BatchWrite(ops []BatchOp, metadata []byte) (uint64, error) {
 	return getU64(body[0:8]), nil
 }
 
+func (c *KVClient) CompareBatchWrite(checks []CompareCheck, ops []BatchOp, metadata []byte) (uint64, bool, error) {
+	if len(ops) == 0 || len(ops) > maxOperations {
+		return 0, false, fmt.Errorf("pkbfi: invalid operation count")
+	}
+	if len(checks) > maxOperations {
+		return 0, false, fmt.Errorf("pkbfi: too many compare checks")
+	}
+	if len(metadata) > maxTransactionSize-16 {
+		return 0, false, fmt.Errorf("pkbfi: batch metadata exceeds transaction limit")
+	}
+	payloadSize := 16 + len(metadata)
+	for _, check := range checks {
+		if err := validateKey(check.Key); err != nil {
+			return 0, false, err
+		}
+		payloadSize += 16 + len(check.Key)
+		if payloadSize > maxFrameSize {
+			return 0, false, fmt.Errorf("pkbfi: compare batch exceeds frame limit")
+		}
+	}
+	for _, op := range ops {
+		if op.Op != batchPut && op.Op != batchDelete {
+			return 0, false, fmt.Errorf("pkbfi: invalid batch opcode %d", op.Op)
+		}
+		if err := validateKey(op.Key); err != nil {
+			return 0, false, err
+		}
+		if err := validateValue(op.Value); err != nil {
+			return 0, false, err
+		}
+		if op.Op == batchDelete && len(op.Value) != 0 {
+			return 0, false, fmt.Errorf("pkbfi: delete operation with value")
+		}
+		payloadSize += 12 + len(op.Key) + len(op.Value)
+		if payloadSize > maxTransactionSize {
+			return 0, false, fmt.Errorf("pkbfi: batch exceeds transaction limit")
+		}
+	}
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	payload := make([]byte, 16, payloadSize)
+	putU32(payload[0:4], uint32(len(checks)))
+	putU32(payload[4:8], uint32(len(ops)))
+	putU32(payload[8:12], uint32(len(metadata)))
+	for _, check := range checks {
+		var header [16]byte
+		putU32(header[0:4], uint32(len(check.Key)))
+		putU64(header[8:16], check.LSN)
+		payload = append(payload, header[:]...)
+		payload = append(payload, check.Key...)
+	}
+	payload = append(payload, metadata...)
+	for _, op := range ops {
+		var header [12]byte
+		header[0] = op.Op
+		putU32(header[4:8], uint32(len(op.Key)))
+		putU32(header[8:12], uint32(len(op.Value)))
+		payload = append(payload, header[:]...)
+		payload = append(payload, op.Key...)
+		payload = append(payload, op.Value...)
+	}
+	status, body, err := c.request(opCompareBatch, payload)
+	if err != nil {
+		return 0, false, err
+	}
+	if status != statusOK || len(body) != 16 {
+		return 0, false, fmt.Errorf("%w: malformed compare_batch_write response", ErrProtocol)
+	}
+	committed := body[0] != 0
+	lsn := getU64(body[8:16])
+	return lsn, committed, nil
+}
+
 func (c *KVClient) Scan(prefix []byte) (*ScanCursor, error) {
 	return c.openScan(prefix, true, scanPageSize)
 }

+ 290 - 0
pkg/storage/kv_test.go

@@ -637,3 +637,293 @@ func TestPoolReconnectsAfterBrokenConnection(t *testing.T) {
 		t.Fatalf("stale idle connection was not replaced before use: %v", err)
 	}
 }
+
+func compareBatchResponseBody(committed bool, lsn uint64) []byte {
+	body := make([]byte, 18)
+	putU16(body[0:2], statusOK)
+	if committed {
+		body[2] = 1
+	}
+	putU64(body[10:18], lsn)
+	return body
+}
+
+func parseComparePayload(payload []byte) ([]CompareCheck, []BatchOp, bool) {
+	if len(payload) < 16 {
+		return nil, nil, false
+	}
+	checkCount := getU32(payload[0:4])
+	opCount := getU32(payload[4:8])
+	metadataLen := getU32(payload[8:12])
+	pos := 16
+	checks := make([]CompareCheck, 0, checkCount)
+	for i := uint32(0); i < checkCount; i++ {
+		if len(payload)-pos < 16 {
+			return nil, nil, false
+		}
+		keyLen := getU32(payload[pos : pos+4])
+		lsn := getU64(payload[pos+8 : pos+16])
+		pos += 16
+		if keyLen > maxKeySize || len(payload)-pos < int(keyLen) {
+			return nil, nil, false
+		}
+		checks = append(checks, CompareCheck{Key: payload[pos : pos+int(keyLen)], LSN: lsn})
+		pos += int(keyLen)
+	}
+	if len(payload)-pos < int(metadataLen) {
+		return nil, nil, false
+	}
+	pos += int(metadataLen)
+	ops := make([]BatchOp, 0, opCount)
+	for i := uint32(0); i < opCount; i++ {
+		if len(payload)-pos < 12 {
+			return nil, nil, false
+		}
+		op := payload[pos]
+		keyLen := getU32(payload[pos+4 : pos+8])
+		valueLen := getU32(payload[pos+8 : pos+12])
+		pos += 12
+		if keyLen > maxKeySize || valueLen > maxValueSize || len(payload)-pos < int(keyLen)+int(valueLen) {
+			return nil, nil, false
+		}
+		ops = append(ops, BatchOp{
+			Op:    op,
+			Key:   payload[pos : pos+int(keyLen)],
+			Value: payload[pos+int(keyLen) : pos+int(keyLen)+int(valueLen)],
+		})
+		pos += int(keyLen) + int(valueLen)
+	}
+	if pos != len(payload) {
+		return nil, nil, false
+	}
+	return checks, ops, true
+}
+
+func TestCompareBatchWriteClientSemantics(t *testing.T) {
+	clientConn, serverConn := net.Pipe()
+	defer clientConn.Close()
+	c := pipeClient(clientConn)
+
+	state := make(map[string]uint64)
+	var nextLSN uint64
+
+	go func() {
+		defer serverConn.Close()
+		r := bufio.NewReader(serverConn)
+		for i := 0; i < 3; i++ {
+			opcode, _, requestID, payload, err := readFrame(r)
+			if err != nil {
+				return
+			}
+			if opcode != opCompareBatch {
+				serverConn.Write(encodeResponse(opcode, requestID, errorBody("UnknownOpcode")))
+				continue
+			}
+			checks, ops, ok := parseComparePayload(payload)
+			if !ok {
+				serverConn.Write(encodeResponse(opcode, requestID, errorBody("InvalidPayload")))
+				continue
+			}
+			committed := true
+			for _, check := range checks {
+				current, found := state[string(check.Key)]
+				if check.LSN == 0 {
+					if found {
+						committed = false
+					}
+				} else if !found || current != check.LSN {
+					committed = false
+				}
+			}
+			responseLSN := uint64(0)
+			if committed {
+				nextLSN++
+				responseLSN = nextLSN
+				for _, op := range ops {
+					if op.Op == batchPut {
+						state[string(op.Key)] = nextLSN
+					} else {
+						delete(state, string(op.Key))
+					}
+				}
+			}
+			serverConn.Write(encodeResponse(opcode, requestID, compareBatchResponseBody(committed, responseLSN)))
+		}
+	}()
+
+	lsn, committed, err := c.CompareBatchWrite(
+		[]CompareCheck{{Key: []byte("k"), LSN: 0}},
+		[]BatchOp{{Op: batchPut, Key: []byte("k"), Value: []byte("v")}},
+		[]byte("meta"),
+	)
+	if err != nil {
+		t.Fatalf("absent check: %v", err)
+	}
+	if !committed || lsn != 1 {
+		t.Fatalf("absent check committed=%v lsn=%d, want committed lsn=1", committed, lsn)
+	}
+
+	lsn, committed, err = c.CompareBatchWrite(
+		[]CompareCheck{{Key: []byte("k"), LSN: 1}},
+		[]BatchOp{{Op: batchPut, Key: []byte("k"), Value: []byte("v2")}},
+		nil,
+	)
+	if err != nil {
+		t.Fatalf("matching lsn: %v", err)
+	}
+	if !committed || lsn != 2 {
+		t.Fatalf("matching lsn committed=%v lsn=%d, want committed lsn=2", committed, lsn)
+	}
+
+	lsn, committed, err = c.CompareBatchWrite(
+		[]CompareCheck{{Key: []byte("k"), LSN: 1}},
+		[]BatchOp{{Op: batchPut, Key: []byte("k"), Value: []byte("v3")}},
+		nil,
+	)
+	if err != nil {
+		t.Fatalf("stale lsn: %v", err)
+	}
+	if committed || lsn != 0 {
+		t.Fatalf("stale lsn committed=%v lsn=%d, want conflict committed=false lsn=0", committed, lsn)
+	}
+}
+
+func TestCompareBatchWriteWireFormat(t *testing.T) {
+	clientConn, serverConn := net.Pipe()
+	defer clientConn.Close()
+	c := pipeClient(clientConn)
+
+	var captured []byte
+	go func() {
+		defer serverConn.Close()
+		r := bufio.NewReader(serverConn)
+		opcode, _, requestID, payload, err := readFrame(r)
+		if err != nil {
+			return
+		}
+		captured = payload
+		serverConn.Write(encodeResponse(opcode, requestID, compareBatchResponseBody(true, 7)))
+	}()
+
+	checks := []CompareCheck{
+		{Key: []byte("a"), LSN: 0},
+		{Key: []byte("bb"), LSN: 42},
+	}
+	ops := []BatchOp{
+		{Op: batchPut, Key: []byte("x"), Value: []byte("yy")},
+		{Op: batchDelete, Key: []byte("z")},
+	}
+	if _, _, err := c.CompareBatchWrite(checks, ops, []byte("m")); err != nil {
+		t.Fatalf("compare batch: %v", err)
+	}
+
+	if !bytes.Equal(captured[0:4], []byte{2, 0, 0, 0}) {
+		t.Fatalf("check count = %v", captured[0:4])
+	}
+	if !bytes.Equal(captured[4:8], []byte{2, 0, 0, 0}) {
+		t.Fatalf("op count = %v", captured[4:8])
+	}
+	if !bytes.Equal(captured[8:12], []byte{1, 0, 0, 0}) {
+		t.Fatalf("metadata len = %v", captured[8:12])
+	}
+	if !bytes.Equal(captured[12:16], []byte{0, 0, 0, 0}) {
+		t.Fatalf("reserved = %v", captured[12:16])
+	}
+	pos := 16
+	if !bytes.Equal(captured[pos:pos+4], []byte{1, 0, 0, 0}) {
+		t.Fatalf("check0 key len = %v", captured[pos:pos+4])
+	}
+	if getU64(captured[pos+8:pos+16]) != 0 {
+		t.Fatalf("check0 lsn = %d", getU64(captured[pos+8:pos+16]))
+	}
+	if !bytes.Equal(captured[pos+16:pos+17], []byte("a")) {
+		t.Fatalf("check0 key = %q", captured[pos+16:pos+17])
+	}
+	pos += 17
+	if !bytes.Equal(captured[pos:pos+4], []byte{2, 0, 0, 0}) {
+		t.Fatalf("check1 key len = %v", captured[pos:pos+4])
+	}
+	if getU64(captured[pos+8:pos+16]) != 42 {
+		t.Fatalf("check1 lsn = %d", getU64(captured[pos+8:pos+16]))
+	}
+	if !bytes.Equal(captured[pos+16:pos+18], []byte("bb")) {
+		t.Fatalf("check1 key = %q", captured[pos+16:pos+18])
+	}
+	pos += 18
+	if !bytes.Equal(captured[pos:pos+1], []byte("m")) {
+		t.Fatalf("metadata = %q", captured[pos:pos+1])
+	}
+	pos++
+	if captured[pos] != batchPut {
+		t.Fatalf("op0 opcode = %d", captured[pos])
+	}
+	if !bytes.Equal(captured[pos+4:pos+8], []byte{1, 0, 0, 0}) {
+		t.Fatalf("op0 key len = %v", captured[pos+4:pos+8])
+	}
+	if !bytes.Equal(captured[pos+8:pos+12], []byte{2, 0, 0, 0}) {
+		t.Fatalf("op0 value len = %v", captured[pos+8:pos+12])
+	}
+	pos += 12
+	if !bytes.Equal(captured[pos:pos+3], []byte("xyy")) {
+		t.Fatalf("op0 body = %q", captured[pos:pos+3])
+	}
+	pos += 3
+	if captured[pos] != batchDelete {
+		t.Fatalf("op1 opcode = %d", captured[pos])
+	}
+	if !bytes.Equal(captured[pos+4:pos+8], []byte{1, 0, 0, 0}) {
+		t.Fatalf("op1 key len = %v", captured[pos+4:pos+8])
+	}
+	if !bytes.Equal(captured[pos+8:pos+12], []byte{0, 0, 0, 0}) {
+		t.Fatalf("op1 value len = %v", captured[pos+8:pos+12])
+	}
+	pos += 12
+	if !bytes.Equal(captured[pos:pos+1], []byte("z")) {
+		t.Fatalf("op1 key = %q", captured[pos:pos+1])
+	}
+	pos++
+	if pos != len(captured) {
+		t.Fatalf("payload trailing bytes: got len %d want %d", len(captured), pos)
+	}
+}
+
+func TestCompareBatchWriteRejectsInvalidInput(t *testing.T) {
+	c := &KVClient{}
+	largeKey := make([]byte, maxKeySize+1)
+	if _, _, err := c.CompareBatchWrite(nil, nil, nil); err == nil {
+		t.Fatal("accepted empty ops")
+	}
+	if _, _, err := c.CompareBatchWrite([]CompareCheck{{Key: largeKey}}, []BatchOp{{Op: batchPut, Key: []byte("k")}}, nil); err == nil {
+		t.Fatal("accepted oversized check key")
+	}
+	if _, _, err := c.CompareBatchWrite(nil, []BatchOp{{Op: 99, Key: []byte("k")}}, nil); err == nil {
+		t.Fatal("accepted invalid batch opcode")
+	}
+	if _, _, err := c.CompareBatchWrite(nil, []BatchOp{{Op: batchDelete, Key: []byte("k"), Value: []byte("v")}}, nil); err == nil {
+		t.Fatal("accepted delete with value")
+	}
+}
+
+func TestCompareBatchWriteMalformedResponse(t *testing.T) {
+	clientConn, serverConn := net.Pipe()
+	defer clientConn.Close()
+	c := pipeClient(clientConn)
+
+	go func() {
+		defer serverConn.Close()
+		r := bufio.NewReader(serverConn)
+		opcode, _, requestID, _, err := readFrame(r)
+		if err != nil {
+			return
+		}
+		serverConn.Write(encodeResponse(opcode, requestID, []byte{0, 0, 1}))
+	}()
+
+	if _, _, err := c.CompareBatchWrite(
+		[]CompareCheck{{Key: []byte("k"), LSN: 0}},
+		[]BatchOp{{Op: batchPut, Key: []byte("k"), Value: []byte("v")}},
+		nil,
+	); !errors.Is(err, ErrProtocol) {
+		t.Fatalf("err = %v, want ErrProtocol", err)
+	}
+}

+ 91 - 0
pkg/storage/pizzakv_integration_test.go

@@ -7,6 +7,7 @@ import (
 	"os/exec"
 	"path/filepath"
 	"sync"
+	"sync/atomic"
 	"testing"
 	"time"
 
@@ -140,6 +141,96 @@ func TestPizzaKVIntegration(t *testing.T) {
 	}
 }
 
+func TestPizzaKVCompareBatchIntegration(t *testing.T) {
+	binary := os.Getenv("PIZZAKV_BIN")
+	if binary == "" {
+		t.Skip("PIZZAKV_BIN is not set")
+	}
+
+	dir := t.TempDir()
+	socket := shortPizzaKVSocket(t)
+	database := filepath.Join(dir, "compare.pkvdb")
+	startPizzaKVTest(t, binary, socket, database)
+	pool := waitPizzaKVPool(t, socket)
+	defer pool.Close()
+
+	var seededLSN uint64
+	if err := pool.WithClient(func(c *KVClient) error {
+		lsn, committed, err := c.CompareBatchWrite(
+			[]CompareCheck{{Key: []byte("k"), LSN: 0}},
+			[]BatchOp{{Op: batchPut, Key: []byte("k"), Value: []byte("v")}},
+			nil,
+		)
+		if err != nil {
+			return err
+		}
+		if !committed || lsn == 0 {
+			return fmt.Errorf("absent check expected commit, committed=%v lsn=%d", committed, lsn)
+		}
+		seededLSN = lsn
+		return nil
+	}); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	if err := pool.WithClient(func(c *KVClient) error {
+		lsn, committed, err := c.CompareBatchWrite(
+			[]CompareCheck{{Key: []byte("k"), LSN: 0}},
+			[]BatchOp{{Op: batchPut, Key: []byte("k"), Value: []byte("x")}},
+			nil,
+		)
+		if err != nil {
+			return err
+		}
+		if committed || lsn != 0 {
+			return fmt.Errorf("expected conflict, committed=%v lsn=%d", committed, lsn)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("stale conflict: %v", err)
+	}
+
+	const workers = 8
+	var wins atomic.Int32
+	var wg sync.WaitGroup
+	errCh := make(chan error, workers)
+	for i := 0; i < workers; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			if err := pool.WithClient(func(c *KVClient) error {
+				lsn, committed, err := c.CompareBatchWrite(
+					[]CompareCheck{{Key: []byte("k"), LSN: seededLSN}},
+					[]BatchOp{{Op: batchPut, Key: []byte("k"), Value: []byte("winner")}},
+					nil,
+				)
+				if err != nil {
+					return err
+				}
+				if committed {
+					if lsn <= seededLSN {
+						return fmt.Errorf("winner lsn %d did not advance past %d", lsn, seededLSN)
+					}
+					wins.Add(1)
+				}
+				return nil
+			}); err != nil {
+				errCh <- err
+			}
+		}()
+	}
+	wg.Wait()
+	close(errCh)
+	for err := range errCh {
+		if err != nil {
+			t.Fatalf("concurrent: %v", err)
+		}
+	}
+	if got := wins.Load(); got != 1 {
+		t.Fatalf("exactly one transaction should win, got %d", got)
+	}
+}
+
 func TestPizzaKVLegacyMigrationIntegration(t *testing.T) {
 	binary := os.Getenv("PIZZAKV_BIN")
 	if binary == "" {

+ 93 - 86
pkg/storage/schema.go

@@ -45,45 +45,71 @@ type IndexColumn struct {
 	Desc bool   `json:"desc"`
 }
 
+// rowIDAllocator owns the next-ROWID state for a single table. It is a separate
+// mutex per table so allocating a ROWID on one table never serializes against
+// another table, and never contends with the SchemaManager catalog lock.
+type rowIDAllocator struct {
+	mu   sync.Mutex
+	next int64
+	init bool
+}
+
 // SchemaManager manages table schemas.
 type SchemaManager struct {
-	pool             *KVPool
-	database         string
-	cache            map[string]*Schema
-	indexCache       map[string]*Index
-	indexListCache   []string
-	indexListCached  bool
-	rowIDInitialized map[string]bool
-	version          uint64
-	mu               sync.RWMutex
-	txMu             sync.RWMutex
-	tableLocksMu     sync.Mutex
-	tableLocks       map[string]*sync.RWMutex
+	pool            *KVPool
+	database        string
+	cache           map[string]*Schema
+	indexCache      map[string]*Index
+	indexListCache  []string
+	indexListCached bool
+	version         uint64
+	mu              sync.RWMutex
+	tableLocksMu    sync.Mutex
+	tableLocks      map[string]*sync.RWMutex
+
+	rowIDMu    sync.Mutex
+	rowIDAlloc map[string]*rowIDAllocator
 }
 
-// BeginTransaction prevents other connections from observing intermediate
-// changes until this connection commits or rolls back.
-func (m *SchemaManager) BeginTransaction() { m.txMu.Lock() }
+// BeginTransaction is retained for API compatibility. Buffered per-session
+// transactions no longer take a database-wide transaction lock; staged writes
+// are validated and committed atomically with CompareBatchWrite instead.
+func (m *SchemaManager) BeginTransaction() {}
 
-// EndTransaction releases the database transaction lock.
-func (m *SchemaManager) EndTransaction() { m.txMu.Unlock() }
+// EndTransaction is retained for API compatibility.
+func (m *SchemaManager) EndTransaction() {}
 
-// LockStatement serializes a non-transactional statement with transactions.
-func (m *SchemaManager) LockStatement() { m.txMu.RLock() }
+// LockStatement is retained for API compatibility. Statement execution is now
+// serialized through per-table locks and optimistic validation, so no global
+// statement lock is required.
+func (m *SchemaManager) LockStatement() {}
 
-// UnlockStatement releases a non-transactional statement lock.
-func (m *SchemaManager) UnlockStatement() { m.txMu.RUnlock() }
+// UnlockStatement is retained for API compatibility.
+func (m *SchemaManager) UnlockStatement() {}
 
 // NewSchemaManager creates a new schema manager.
 func NewSchemaManager(pool *KVPool, database string) *SchemaManager {
 	return &SchemaManager{
-		pool:             pool,
-		database:         database,
-		cache:            make(map[string]*Schema),
-		indexCache:       make(map[string]*Index),
-		rowIDInitialized: make(map[string]bool),
-		tableLocks:       make(map[string]*sync.RWMutex),
+		pool:       pool,
+		database:   database,
+		cache:      make(map[string]*Schema),
+		indexCache: make(map[string]*Index),
+		tableLocks: make(map[string]*sync.RWMutex),
+		rowIDAlloc: make(map[string]*rowIDAllocator),
+	}
+}
+
+// rowIDAllocatorFor returns (creating if needed) the per-table ROWID allocator.
+func (m *SchemaManager) rowIDAllocatorFor(table string) *rowIDAllocator {
+	key := strings.ToLower(table)
+	m.rowIDMu.Lock()
+	a, ok := m.rowIDAlloc[key]
+	if !ok {
+		a = &rowIDAllocator{}
+		m.rowIDAlloc[key] = a
 	}
+	m.rowIDMu.Unlock()
+	return a
 }
 
 func (m *SchemaManager) tableLock(table string) *sync.RWMutex {
@@ -248,7 +274,9 @@ func (m *SchemaManager) DropTable(name string) error {
 	// Update cache
 	tableLower := strings.ToLower(name)
 	delete(m.cache, tableLower)
-	delete(m.rowIDInitialized, tableLower)
+	m.rowIDMu.Lock()
+	delete(m.rowIDAlloc, tableLower)
+	m.rowIDMu.Unlock()
 	m.bumpVersionLocked()
 
 	return nil
@@ -402,7 +430,9 @@ func (m *SchemaManager) InvalidateCache(name string) {
 	defer m.mu.Unlock()
 	tableLower := strings.ToLower(name)
 	delete(m.cache, tableLower)
-	delete(m.rowIDInitialized, tableLower)
+	m.rowIDMu.Lock()
+	delete(m.rowIDAlloc, tableLower)
+	m.rowIDMu.Unlock()
 }
 
 // ToAnalyzerTableInfo converts a Schema to analyzer.TableInfo.
@@ -435,80 +465,59 @@ func (s *Schema) GetColumn(name string) (*Column, bool) {
 	return nil, false
 }
 
-// GetNextRowID gets and increments the next ROWID for a table.
+// GetNextRowID gets and increments the next ROWID for a table. Allocation is
+// serialized per table via the table's own allocator so inserts on different
+// tables never contend, and no global SchemaManager lock is held across the
+// durable derivation scan.
 func (m *SchemaManager) GetNextRowID(table string) (int64, error) {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	schema, err := m.getSchemaLocked(table)
+	alloc := m.rowIDAllocatorFor(table)
+	alloc.mu.Lock()
+	defer alloc.mu.Unlock()
+	next, err := m.nextRowIDLocked(alloc, table)
 	if err != nil {
 		return 0, err
 	}
-
-	nextRowID, err := m.getNextRowIDLocked(schema)
-	if err != nil {
-		return 0, err
-	}
-
-	schema.NextRowID = nextRowID + 1
-
-	return nextRowID, nil
+	alloc.next = next + 1
+	return next, nil
 }
 
 // UpdateMaxRowID updates the next ROWID if the provided value is higher.
 func (m *SchemaManager) UpdateMaxRowID(table string, rowid int64) error {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	schema, err := m.getSchemaLocked(table)
+	alloc := m.rowIDAllocatorFor(table)
+	alloc.mu.Lock()
+	defer alloc.mu.Unlock()
+	next, err := m.nextRowIDLocked(alloc, table)
 	if err != nil {
 		return err
 	}
-
-	nextRowID, err := m.getNextRowIDLocked(schema)
-	if err != nil {
-		return err
+	if rowid >= next {
+		alloc.next = rowid + 1
 	}
-
-	if rowid >= nextRowID {
-		schema.NextRowID = rowid + 1
-	}
-
 	return nil
 }
 
-// getNextRowIDLocked returns a table's in-memory ROWID counter (must hold lock).
-// On first use after startup, the counter is derived from durable row data so
-// ROWID movement does not add a separate WAL entry.
-func (m *SchemaManager) getNextRowIDLocked(schema *Schema) (int64, error) {
-	tableLower := strings.ToLower(schema.Name)
-	if m.rowIDInitialized[tableLower] {
-		if schema.NextRowID < 1 {
-			schema.NextRowID = 1
-		}
-		return schema.NextRowID, nil
+// nextRowIDLocked returns the current next ROWID, deriving it from durable rows
+// on first use (must hold the per-table allocator lock).
+func (m *SchemaManager) nextRowIDLocked(alloc *rowIDAllocator, table string) (int64, error) {
+	if alloc.init {
+		return alloc.next, nil
 	}
-
-	nextRowID, err := m.deriveNextRowIDLocked(schema)
+	next, err := m.deriveNextRowID(table)
 	if err != nil {
 		return 0, err
 	}
-	if schema.NextRowID > nextRowID {
-		nextRowID = schema.NextRowID
+	if next < 1 {
+		next = 1
 	}
-	if nextRowID < 1 {
-		nextRowID = 1
-	}
-
-	schema.NextRowID = nextRowID
-	m.rowIDInitialized[tableLower] = true
-	return schema.NextRowID, nil
+	alloc.next = next
+	alloc.init = true
+	return alloc.next, nil
 }
 
-// deriveNextRowIDLocked scans durable row keys to recover max(rowid)+1,
-// streaming each page through decodeRow instead of materializing every value.
-func (m *SchemaManager) deriveNextRowIDLocked(schema *Schema) (int64, error) {
-	prefix := []byte(fmt.Sprintf("%s:_data:%s:", m.database, strings.ToLower(schema.Name)))
+// deriveNextRowID scans durable row keys to recover max(rowid)+1, streaming
+// each page through decodeRow instead of materializing every value.
+func (m *SchemaManager) deriveNextRowID(table string) (int64, error) {
+	prefix := []byte(fmt.Sprintf("%s:_data:%s:", m.database, strings.ToLower(table)))
 	var maxRowID int64
 	err := m.pool.WithClient(func(client *KVClient) (retErr error) {
 		cursor, err := client.Scan(prefix)
@@ -1000,9 +1009,10 @@ func (m *SchemaManager) RenameTable(oldName, newName string) error {
 	// Update cache
 	oldLower := strings.ToLower(oldName)
 	newLower := strings.ToLower(newName)
-	wasInitialized := m.rowIDInitialized[oldLower]
 	delete(m.cache, oldLower)
-	delete(m.rowIDInitialized, oldLower)
+	m.rowIDMu.Lock()
+	delete(m.rowIDAlloc, oldLower)
+	m.rowIDMu.Unlock()
 
 	// Write new schema
 	newKey := m.schemaKey(newName)
@@ -1019,9 +1029,6 @@ func (m *SchemaManager) RenameTable(oldName, newName string) error {
 
 	// Update cache
 	m.cache[newLower] = schema
-	if wasInitialized {
-		m.rowIDInitialized[newLower] = true
-	}
 	m.bumpVersionLocked()
 
 	return nil

+ 103 - 0
pkg/storage/schema_test.go

@@ -276,6 +276,47 @@ func (s *testKVServer) execute(opcode uint16, payload []byte, scans map[uint64]*
 		putU16(body[0:2], statusOK)
 		putU64(body[2:10], lsn)
 		return body
+	case opCompareBatch:
+		checks, ops, ok := parseCompareBatch(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		committed := true
+		for _, check := range checks {
+			if check.LSN == 0 {
+				if _, found := s.data[string(check.Key)]; found {
+					committed = false
+					break
+				}
+			} else if s.lsns[string(check.Key)] != check.LSN {
+				committed = false
+				break
+			}
+		}
+		var lsn uint64
+		if committed {
+			s.nextLSN++
+			lsn = s.nextLSN
+			for _, op := range ops {
+				if op.Op == batchPut {
+					s.data[string(op.Key)] = append([]byte(nil), op.Value...)
+					s.lsns[string(op.Key)] = lsn
+					s.writes[string(op.Key)]++
+				} else {
+					delete(s.data, string(op.Key))
+					delete(s.lsns, string(op.Key))
+				}
+			}
+		}
+		s.mu.Unlock()
+		body := make([]byte, 18)
+		putU16(body[0:2], statusOK)
+		if committed {
+			body[2] = 1
+		}
+		putU64(body[10:18], lsn)
+		return body
 	case opScanOpen:
 		includeValues, limit, prefix, ok := parseScanOpen(payload)
 		if !ok {
@@ -446,6 +487,68 @@ func parseBatchOps(payload []byte) ([]BatchOp, bool) {
 	return ops, pos == len(payload)
 }
 
+func parseCompareBatch(payload []byte) ([]CompareCheck, []BatchOp, bool) {
+	if len(payload) < 16 {
+		return nil, nil, false
+	}
+	numChecks := getU32(payload[0:4])
+	numOps := getU32(payload[4:8])
+	metadataLen := getU32(payload[8:12])
+	if numChecks > maxOperations || numOps == 0 || numOps > maxOperations {
+		return nil, nil, false
+	}
+	if uint64(16)+uint64(metadataLen) > uint64(len(payload)) {
+		return nil, nil, false
+	}
+	pos := 16
+	checks := make([]CompareCheck, 0, numChecks)
+	for i := uint32(0); i < numChecks; i++ {
+		if len(payload)-pos < 16 {
+			return nil, nil, false
+		}
+		keyLen := getU32(payload[pos : pos+4])
+		lsn := getU64(payload[pos+8 : pos+16])
+		pos += 16
+		if keyLen > maxKeySize || len(payload)-pos < int(keyLen) {
+			return nil, nil, false
+		}
+		checks = append(checks, CompareCheck{Key: payload[pos : pos+int(keyLen)], LSN: lsn})
+		pos += int(keyLen)
+	}
+	pos += int(metadataLen)
+	if pos > len(payload) {
+		return nil, nil, false
+	}
+	ops := make([]BatchOp, 0, numOps)
+	for i := uint32(0); i < numOps; i++ {
+		if len(payload)-pos < 12 {
+			return nil, nil, false
+		}
+		opcode := payload[pos]
+		keyLen := getU32(payload[pos+4 : pos+8])
+		valueLen := getU32(payload[pos+8 : pos+12])
+		pos += 12
+		if opcode != batchPut && opcode != batchDelete {
+			return nil, nil, false
+		}
+		if keyLen > maxKeySize || valueLen > maxValueSize {
+			return nil, nil, false
+		}
+		if opcode == batchDelete && valueLen != 0 {
+			return nil, nil, false
+		}
+		if len(payload)-pos < int(keyLen)+int(valueLen) {
+			return nil, nil, false
+		}
+		key := payload[pos : pos+int(keyLen)]
+		pos += int(keyLen)
+		value := payload[pos : pos+int(valueLen)]
+		pos += int(valueLen)
+		ops = append(ops, BatchOp{Op: opcode, Key: key, Value: value})
+	}
+	return checks, ops, pos == len(payload)
+}
+
 func parseScanOpen(payload []byte) (bool, uint32, []byte, bool) {
 	if len(payload) < 12 {
 		return false, 0, nil, false

+ 426 - 141
pkg/storage/table.go

@@ -2,6 +2,7 @@ package storage
 
 import (
 	"fmt"
+	"hash/fnv"
 	"math"
 	"strings"
 	"sync"
@@ -31,21 +32,35 @@ type TableManager struct {
 	counts          map[string]int
 	countsInit      map[string]bool
 	countGeneration map[string]time.Time
+
+	// stripes are deterministic per-key locks used by point operations
+	// (GetByPK/Insert/UpdateByPK/DeleteByPK). They replace the table-wide lock
+	// so point operations on different keys of the same table proceed
+	// concurrently. Index is derived from the full data key (database+table+pk).
+	stripes [64]sync.Mutex
+
+	// generations tracks full-table scans. predicateGenerations narrows indexed
+	// equality reads to one index value so unrelated writes do not conflict.
+	genMu                sync.Mutex
+	generations          map[string]uint64
+	predicateGenerations map[string]uint64
 }
 
 // NewTableManager creates a new table manager.
 func NewTableManager(pool *KVPool, schema *SchemaManager, database string) *TableManager {
 	return &TableManager{
-		pool:            pool,
-		schema:          schema,
-		database:        database,
-		indexCache:      make(map[string]map[string][]int64),
-		indexTable:      make(map[string]string),
-		rowKeyCache:     make(map[string]map[int64]string),
-		disabledIndexes: make(map[string]bool),
-		counts:          make(map[string]int),
-		countsInit:      make(map[string]bool),
-		countGeneration: make(map[string]time.Time),
+		pool:                 pool,
+		schema:               schema,
+		database:             database,
+		indexCache:           make(map[string]map[string][]int64),
+		indexTable:           make(map[string]string),
+		rowKeyCache:          make(map[string]map[int64]string),
+		disabledIndexes:      make(map[string]bool),
+		counts:               make(map[string]int),
+		countsInit:           make(map[string]bool),
+		countGeneration:      make(map[string]time.Time),
+		generations:          make(map[string]uint64),
+		predicateGenerations: make(map[string]uint64),
 	}
 }
 
@@ -53,6 +68,99 @@ func (m *TableManager) tableLock(key string) *sync.RWMutex {
 	return m.schema.tableLock(key)
 }
 
+// stripeKey returns the deterministic striped lock for a point operation on the
+// given full data key. Point operations on different keys therefore serialize
+// independently, while operations on the same key are mutually exclusive.
+func (m *TableManager) stripeKey(key string) *sync.Mutex {
+	h := fnv.New32a()
+	h.Write([]byte(key))
+	return &m.stripes[h.Sum32()%uint32(len(m.stripes))]
+}
+
+// generation returns the current in-process generation for a table. It is
+// bumped on every committed write and captured by transaction scans.
+func (m *TableManager) generation(table string) uint64 {
+	key := strings.ToLower(table)
+	m.genMu.Lock()
+	g := m.generations[key]
+	m.genMu.Unlock()
+	return g
+}
+
+// bumpGeneration advances a table's generation. Callers hold the table gate in
+// shared mode for point writes or exclusive mode for scan-based writes.
+func (m *TableManager) bumpGeneration(table string) {
+	key := strings.ToLower(table)
+	m.genMu.Lock()
+	m.generations[key]++
+	m.genMu.Unlock()
+}
+
+func indexPredicateKey(table, indexName, value string) string {
+	return strings.ToLower(table) + "\x00" + strings.ToLower(indexName) + "\x00" + value
+}
+
+func indexPredicateWildcardKey(table string) string {
+	return strings.ToLower(table) + "\x00*"
+}
+
+func (m *TableManager) predicateSnapshot(table, indexName, value string) (string, uint64, string, uint64) {
+	valueKey := indexPredicateKey(table, indexName, value)
+	wildcardKey := indexPredicateWildcardKey(table)
+	m.genMu.Lock()
+	valueGen := m.predicateGenerations[valueKey]
+	wildcardGen := m.predicateGenerations[wildcardKey]
+	m.genMu.Unlock()
+	return valueKey, valueGen, wildcardKey, wildcardGen
+}
+
+func (m *TableManager) predicateGeneration(key string) uint64 {
+	m.genMu.Lock()
+	gen := m.predicateGenerations[key]
+	m.genMu.Unlock()
+	return gen
+}
+
+func (m *TableManager) bumpIndexPredicates(table string, rows ...Row) {
+	indexes, err := m.schema.ListTableIndexes(table)
+	if err != nil || len(indexes) == 0 {
+		return
+	}
+	m.genMu.Lock()
+	defer m.genMu.Unlock()
+	for _, row := range rows {
+		if row == nil {
+			continue
+		}
+		for _, index := range indexes {
+			columns := make([]string, len(index.Columns))
+			for i, column := range index.Columns {
+				columns[i] = column.Name
+			}
+			value := formatIndexValue(m.buildIndexValue(row, columns))
+			m.predicateGenerations[indexPredicateKey(table, index.Name, value)]++
+		}
+	}
+}
+
+func (m *TableManager) bumpIndexPredicateWildcard(table string) {
+	m.genMu.Lock()
+	m.predicateGenerations[indexPredicateWildcardKey(table)]++
+	m.genMu.Unlock()
+}
+
+// compareWritePoint issues a single CompareBatchWrite against the pooled KV.
+// It returns whether the compare checks held and the ops committed.
+func (m *TableManager) compareWritePoint(checks []CompareCheck, ops []BatchOp) (bool, error) {
+	var committed bool
+	err := m.pool.WithClient(func(c *KVClient) error {
+		_, ok, err := c.CompareBatchWrite(checks, ops, nil)
+		committed = ok
+		return err
+	})
+	return committed, err
+}
+
 // invalidateCache removes a table's derived in-memory indexes.
 func (m *TableManager) invalidateCache(table string) {
 	m.cacheMu.Lock()
@@ -124,6 +232,49 @@ func (m *TableManager) scanRowsWithPageSize(table string, pageSize uint32, fn ro
 	})
 }
 
+// rowWithLSNVisitFunc is like rowVisitFunc but also passes the durable row LSN.
+type rowWithLSNVisitFunc func(row Row, lsn uint64) (stop bool, err error)
+
+// scanRowsWithLSN streams a table's rows together with their durable LSNs. It
+// is used by buffered transactions to capture a per-row read set for
+// compare-and-swap validation at commit.
+func (m *TableManager) scanRowsWithLSN(table string, fn rowWithLSNVisitFunc) error {
+	return m.pool.WithClient(func(client *KVClient) (retErr error) {
+		cursor, err := client.ScanWithLimit([]byte(m.dataPrefix(table)), scanPageSize)
+		if err != nil {
+			return err
+		}
+		defer func() {
+			if err := cursor.Close(); retErr == nil {
+				retErr = err
+			}
+		}()
+
+		for {
+			entries, done, err := cursor.Next()
+			if err != nil {
+				return err
+			}
+			for _, e := range entries {
+				row, err := decodeRow(e.Value)
+				if err != nil {
+					return err
+				}
+				stop, err := fn(row, e.LSN)
+				if err != nil {
+					return err
+				}
+				if stop {
+					return nil
+				}
+			}
+			if done {
+				return nil
+			}
+		}
+	})
+}
+
 // scanCountKeys counts the durable rows of table using a key-only scan so row
 // values are never pulled across the wire. It is used for first-time COUNT(*)
 // derivation.
@@ -163,26 +314,45 @@ func (m *TableManager) scanCountKeys(table string) (int, error) {
 // issued after startup.
 func (m *TableManager) CountFast(table string) (int, error) {
 	key := strings.ToLower(table)
-	tl := m.tableLock(key)
-	tl.Lock()
-	defer tl.Unlock()
 	tableSchema, err := m.schema.GetSchema(table)
 	if err != nil {
 		return 0, err
 	}
 
+	// Cached read path: if the count is initialized for the current table
+	// generation, return it without taking any table lock.
 	m.cacheMu.Lock()
-	if !m.countGeneration[key].Equal(tableSchema.CreatedAt) {
+	if m.countGeneration[key].Equal(tableSchema.CreatedAt) {
+		if m.countsInit[key] {
+			n := m.counts[key]
+			m.cacheMu.Unlock()
+			return n, nil
+		}
+	} else {
 		delete(m.counts, key)
 		delete(m.countsInit, key)
 		m.countGeneration[key] = tableSchema.CreatedAt
 	}
-	init := m.countsInit[key]
-	n := m.counts[key]
 	m.cacheMu.Unlock()
-	if init {
+
+	// First derivation: hold the table write lock so the key-only scan is
+	// exact against concurrent writes.
+	tl := m.tableLock(key)
+	tl.Lock()
+	defer tl.Unlock()
+
+	m.cacheMu.Lock()
+	if !m.countGeneration[key].Equal(tableSchema.CreatedAt) {
+		delete(m.counts, key)
+		delete(m.countsInit, key)
+		m.countGeneration[key] = tableSchema.CreatedAt
+	}
+	if m.countsInit[key] {
+		n := m.counts[key]
+		m.cacheMu.Unlock()
 		return n, nil
 	}
+	m.cacheMu.Unlock()
 
 	count, err := m.scanCountKeys(table)
 	if err != nil {
@@ -197,10 +367,22 @@ func (m *TableManager) CountFast(table string) (int, error) {
 	return count, nil
 }
 
-// incrCount adjusts the derived per-table row count. It is a no-op until the
-// count has been initialized, since an uninitialized count is re-derived from
-// durable rows (which already reflect the write) on next use.
-func (m *TableManager) incrCount(table string, generation time.Time, delta int) {
+// countInitialized reports whether the derived count cache is initialized for
+// the table at this instant. Write paths capture it before their KV write so a
+// concurrent first derivation does not double-count the just-written row.
+func (m *TableManager) countInitialized(table string) bool {
+	key := strings.ToLower(table)
+	m.cacheMu.Lock()
+	init := m.countsInit[key]
+	m.cacheMu.Unlock()
+	return init
+}
+
+// incrCount adjusts the derived per-table row count. wasInit reports whether
+// the count was already initialized before the corresponding write, so a count
+// that was not yet initialized is left to be re-derived from durable rows
+// (which already reflect the write) on next use.
+func (m *TableManager) incrCount(table string, generation time.Time, delta int, wasInit bool) {
 	key := strings.ToLower(table)
 	m.cacheMu.Lock()
 	if !m.countGeneration[key].Equal(generation) {
@@ -208,9 +390,15 @@ func (m *TableManager) incrCount(table string, generation time.Time, delta int)
 		delete(m.countsInit, key)
 		m.countGeneration[key] = generation
 	}
-	if m.countsInit[key] {
+	if wasInit && m.countsInit[key] {
 		m.counts[key] += delta
 	}
+	if !wasInit {
+		// The count was not initialized before the write, so a concurrent first
+		// derivation may have missed the just-written row. Invalidate to force
+		// an exact re-derivation on the next COUNT(*).
+		delete(m.countsInit, key)
+	}
 	m.cacheMu.Unlock()
 }
 
@@ -224,21 +412,20 @@ func (m *TableManager) dataPrefix(table string) string {
 	return fmt.Sprintf("%s:_data:%s:", m.database, strings.ToLower(table))
 }
 
-// Insert inserts a new row.
-func (m *TableManager) Insert(table string, row Row) error {
-	tl := m.tableLock(table)
-	tl.Lock()
-	defer tl.Unlock()
-
+// prepareInsert validates and normalizes an insert row, generating the ROWID
+// when required. It returns the normalized row and the full data key without
+// writing anything, so both the autocommit path (compare-and-swap) and the
+// buffered transaction path (staging) can share it. The input row has its
+// primary key populated as a side effect.
+func (m *TableManager) prepareInsert(table string, row Row) (Row, string, error) {
 	schema, err := m.schema.GetSchema(table)
 	if err != nil {
-		return err
+		return nil, "", err
 	}
 
 	// Get primary key value
 	pkValue, ok := row[schema.PrimaryKey]
 	if !ok {
-		// Try case-insensitive lookup
 		for k, v := range row {
 			if strings.EqualFold(k, schema.PrimaryKey) {
 				pkValue = v
@@ -248,33 +435,29 @@ func (m *TableManager) Insert(table string, row Row) error {
 		}
 	}
 
-	// Check if PK is INTEGER PRIMARY KEY (implicit ROWID alias)
 	pkCol, _ := schema.GetColumn(schema.PrimaryKey)
 	isIntegerPK := pkCol != nil && isIntegerType(pkCol.Type)
 
-	// Auto-generate ROWID if no primary key provided or if it's INTEGER PRIMARY KEY
 	var rowid int64
 	if !ok || pkValue == nil {
 		if isIntegerPK || !ok {
-			// Generate ROWID
 			rowid, err = m.schema.GetNextRowID(table)
 			if err != nil {
-				return err
+				return nil, "", err
 			}
 			pkValue = rowid
 			row[schema.PrimaryKey] = rowid
 			ok = true
 		} else {
-			return fmt.Errorf("missing primary key: %s", schema.PrimaryKey)
+			return nil, "", fmt.Errorf("missing primary key: %s", schema.PrimaryKey)
 		}
 	} else if isIntegerPK {
-		// User provided INTEGER PRIMARY KEY value - track it
 		switch v := pkValue.(type) {
 		case int64:
 			rowid = v
 		case float64:
 			if math.Trunc(v) != v {
-				return fmt.Errorf("invalid integer primary key: %v", v)
+				return nil, "", fmt.Errorf("invalid integer primary key: %v", v)
 			}
 			rowid = int64(v)
 		case int:
@@ -283,35 +466,18 @@ func (m *TableManager) Insert(table string, row Row) error {
 			rowid = 0
 		}
 		if rowid > 0 {
-			m.schema.UpdateMaxRowID(table, rowid)
+			if err := m.schema.UpdateMaxRowID(table, rowid); err != nil {
+				return nil, "", err
+			}
 		}
 	}
 
 	pk := fmt.Sprintf("%v", pkValue)
 
-	// Keep the duplicate check and write in one per-table critical section so
-	// concurrent inserts of the same primary key cannot both persist a single
-	// durable row and double-count it.
-	key := m.dataKey(table, pk)
-	var exists bool
-	err = m.pool.WithClient(func(c *KVClient) error {
-		var e error
-		exists, e = c.Exists([]byte(key))
-		return e
-	})
-	if err != nil {
-		return err
-	}
-	if exists {
-		return fmt.Errorf("duplicate primary key: %s", pk)
-	}
-
-	// Validate required columns
 	for _, col := range schema.Columns {
 		if !col.Nullable && col.Default == nil {
 			val, hasVal := row[col.Name]
 			if !hasVal {
-				// Try case-insensitive lookup
 				for k, v := range row {
 					if strings.EqualFold(k, col.Name) {
 						val = v
@@ -321,12 +487,11 @@ func (m *TableManager) Insert(table string, row Row) error {
 				}
 			}
 			if !hasVal || val == nil {
-				return fmt.Errorf("missing required column: %s", col.Name)
+				return nil, "", fmt.Errorf("missing required column: %s", col.Name)
 			}
 		}
 	}
 
-	// Normalize column names to match schema
 	normalizedRow := make(Row)
 	for _, col := range schema.Columns {
 		for k, v := range row {
@@ -336,44 +501,80 @@ func (m *TableManager) Insert(table string, row Row) error {
 			}
 		}
 	}
-
-	// Apply defaults
 	for _, col := range schema.Columns {
 		if _, ok := normalizedRow[col.Name]; !ok && col.Default != nil {
 			normalizedRow[col.Name] = col.Default
 		}
 	}
 
-	// Store ROWID (use PK value for INTEGER PRIMARY KEY, otherwise generate)
 	if rowid > 0 {
 		normalizedRow["_rowid_"] = rowid
 	} else {
-		// Generate ROWID for non-integer primary keys
-		newRowID, _ := m.schema.GetNextRowID(table)
+		newRowID, err := m.schema.GetNextRowID(table)
+		if err != nil {
+			return nil, "", err
+		}
 		normalizedRow["_rowid_"] = newRowID
 	}
 
-	// Serialize row
-	data, err := encodeRow(normalizedRow)
+	return normalizedRow, m.dataKey(table, pk), nil
+}
+
+// Insert inserts a new row. The duplicate check and write are one atomic
+// compare-and-swap so concurrent inserts of the same primary key cannot both
+// persist. The shared table gate is acquired before the key's striped lock so
+// a queued scan writer cannot invert the lock order with point operations.
+func (m *TableManager) Insert(table string, row Row) error {
+	nr, key, err := m.prepareInsert(table, row)
+	if err != nil {
+		return err
+	}
+	data, err := encodeRow(nr)
 	if err != nil {
 		return fmt.Errorf("failed to serialize row: %w", err)
 	}
+	wasInit := m.countInitialized(table)
 
-	err = m.pool.WithClient(func(c *KVClient) error {
-		_, err := c.Put([]byte(key), data)
-		return err
-	})
+	// Point writers share this gate with each other. Transaction commits and
+	// scan-based writes take it exclusively, so generation validation and cache
+	// publication are ordered without serializing writes to different keys.
+	tl := m.tableLock(table)
+	tl.RLock()
+	defer tl.RUnlock()
+	st := m.stripeKey(key)
+	st.Lock()
+	defer st.Unlock()
+	committed, err := m.compareWritePoint(
+		[]CompareCheck{{Key: []byte(key), LSN: 0}},
+		[]BatchOp{{Op: batchPut, Key: []byte(key), Value: data}},
+	)
 	if err != nil {
 		return err
 	}
+	if !committed {
+		return fmt.Errorf("duplicate primary key: %v", row[schemaPrimaryKey(m.schema, table)])
+	}
 
-	// Update in-memory indexes only. Durable index entries are derived from rows.
-	m.updateIndexesForRow(table, normalizedRow, true)
-
-	m.incrCount(table, schema.CreatedAt, 1)
+	m.updateIndexesForRow(table, nr, true)
+	// Publish derived index state before its generations. A reader that races
+	// with publication either sees the old generation and aborts or sees the
+	// complete new state.
+	m.bumpIndexPredicates(table, nr)
+	m.bumpGeneration(table)
+	if schema, serr := m.schema.GetSchema(table); serr == nil {
+		m.incrCount(table, schema.CreatedAt, 1, wasInit)
+	}
 	return nil
 }
 
+func schemaPrimaryKey(s *SchemaManager, table string) string {
+	schema, err := s.GetSchema(table)
+	if err != nil {
+		return "_rowid_"
+	}
+	return schema.PrimaryKey
+}
+
 // bulkBatchByteBudget bounds a single atomic BATCH_WRITE payload below the
 // PKBFI frame limit so a bulk insert never emits a frame the server rejects.
 // Each op contributes 12 header bytes plus its key and value.
@@ -423,6 +624,7 @@ func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 	if err != nil {
 		return 0, err
 	}
+	wasInit := m.countInitialized(table)
 
 	pkCol, _ := schema.GetColumn(schema.PrimaryKey)
 	isIntegerPK := pkCol != nil && isIntegerType(pkCol.Type)
@@ -557,7 +759,11 @@ func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 	for i := 0; i < numOK; i++ {
 		m.updateIndexesForRow(table, encoded[i], true)
 	}
-	m.incrCount(table, schema.CreatedAt, numOK)
+	if numOK > 0 {
+		m.bumpGeneration(table)
+		m.bumpIndexPredicates(table, encoded[:numOK]...)
+	}
+	m.incrCount(table, schema.CreatedAt, numOK, wasInit)
 
 	return numOK, firstErr
 }
@@ -618,7 +824,11 @@ func (m *TableManager) Select(table string, filter func(Row) bool) ([]Row, error
 	tl := m.tableLock(table)
 	tl.RLock()
 	defer tl.RUnlock()
+	return m.selectRows(table, filter)
+}
 
+// selectRows scans a table while the caller holds its shared or exclusive gate.
+func (m *TableManager) selectRows(table string, filter func(Row) bool) ([]Row, error) {
 	if !m.schema.TableExists(table) {
 		return nil, fmt.Errorf("table not found: %s", table)
 	}
@@ -696,15 +906,13 @@ func (m *TableManager) SelectWithLimit(table string, filter func(Row) bool, limi
 
 // Update updates rows matching the filter.
 func (m *TableManager) Update(table string, updates Row, filter func(Row) bool) (int, error) {
-	// Get all rows
-	rows, err := m.Select(table, filter)
-	if err != nil {
-		return 0, err
-	}
-
 	tl := m.tableLock(strings.ToLower(table))
 	tl.Lock()
 	defer tl.Unlock()
+	rows, err := m.selectRows(table, filter)
+	if err != nil {
+		return 0, err
+	}
 	schema, err := m.schema.GetSchema(table)
 	if err != nil {
 		return 0, err
@@ -748,27 +956,29 @@ func (m *TableManager) Update(table string, updates Row, filter func(Row) bool)
 		if err == nil {
 			// Add new index entries after update
 			m.updateIndexesForRow(table, row, true)
+			m.bumpIndexPredicates(table, oldRow, row)
 			count++
 		} else {
 			m.updateIndexesForRow(table, oldRow, true)
 		}
 	}
 
+	if count > 0 {
+		m.bumpGeneration(table)
+	}
 	return count, nil
 }
 
 // UpdateFunc updates rows matching the filter using a function to compute new values.
 // The updateFn receives the current row and returns the updates to apply.
 func (m *TableManager) UpdateFunc(table string, updateFn func(Row) (Row, error), filter func(Row) bool) (int, error) {
-	// Get all rows
-	rows, err := m.Select(table, filter)
-	if err != nil {
-		return 0, err
-	}
-
 	tl := m.tableLock(strings.ToLower(table))
 	tl.Lock()
 	defer tl.Unlock()
+	rows, err := m.selectRows(table, filter)
+	if err != nil {
+		return 0, err
+	}
 	schema, err := m.schema.GetSchema(table)
 	if err != nil {
 		return 0, err
@@ -817,26 +1027,37 @@ func (m *TableManager) UpdateFunc(table string, updateFn func(Row) (Row, error),
 		if err == nil {
 			// Add new index entries after update
 			m.updateIndexesForRow(table, row, true)
+			m.bumpIndexPredicates(table, oldRow, row)
 			count++
 		} else {
 			m.updateIndexesForRow(table, oldRow, true)
 		}
 	}
 
+	if count > 0 {
+		m.bumpGeneration(table)
+	}
 	return count, nil
 }
 
-// UpdateByPK updates one row without scanning the table.
+// UpdateByPK updates one row without scanning the table. It uses the key's
+// striped lock and a compare-and-swap write so a concurrent modification of the
+// same row fails with a serialization error instead of being silently lost.
 func (m *TableManager) UpdateByPK(table, pk string, updateFn func(Row) (Row, error)) (Row, bool, error) {
-	tl := m.tableLock(table)
-	tl.Lock()
-	defer tl.Unlock()
-
 	schema, err := m.schema.GetSchema(table)
 	if err != nil {
 		return nil, false, err
 	}
-	row, err := m.getByPKUnlocked(table, pk)
+
+	key := m.dataKey(table, pk)
+	tl := m.tableLock(table)
+	tl.RLock()
+	defer tl.RUnlock()
+	st := m.stripeKey(key)
+	st.Lock()
+	defer st.Unlock()
+
+	row, lsn, err := m.getByPKWithLSN(table, pk)
 	if err == ErrKeyNotFound {
 		return nil, false, nil
 	}
@@ -845,10 +1066,8 @@ func (m *TableManager) UpdateByPK(table, pk string, updateFn func(Row) (Row, err
 	}
 
 	oldRow := cloneRow(row)
-	m.updateIndexesForRow(table, oldRow, false)
 	updates, err := updateFn(row)
 	if err != nil {
-		m.updateIndexesForRow(table, oldRow, true)
 		return nil, false, err
 	}
 	for name, value := range updates {
@@ -862,36 +1081,40 @@ func (m *TableManager) UpdateByPK(table, pk string, updateFn func(Row) (Row, err
 
 	data, err := encodeRow(row)
 	if err != nil {
-		m.updateIndexesForRow(table, oldRow, true)
 		return nil, false, err
 	}
-	err = m.pool.WithClient(func(client *KVClient) error {
-		_, err := client.Put([]byte(m.dataKey(table, pk)), data)
-		return err
-	})
+	committed, err := m.compareWritePoint(
+		[]CompareCheck{{Key: []byte(key), LSN: lsn}},
+		[]BatchOp{{Op: batchPut, Key: []byte(key), Value: data}},
+	)
 	if err != nil {
-		m.updateIndexesForRow(table, oldRow, true)
 		return nil, false, err
 	}
+	if !committed {
+		return nil, false, ErrSerialization
+	}
+
+	m.updateIndexesForRow(table, oldRow, false)
 	m.updateIndexesForRow(table, row, true)
+	m.bumpIndexPredicates(table, oldRow, row)
+	m.bumpGeneration(table)
 	return oldRow, true, nil
 }
 
 // Delete deletes rows matching the filter.
 func (m *TableManager) Delete(table string, filter func(Row) bool) (int, error) {
-	// Get all rows
-	rows, err := m.Select(table, filter)
-	if err != nil {
-		return 0, err
-	}
-
 	tl := m.tableLock(strings.ToLower(table))
 	tl.Lock()
 	defer tl.Unlock()
+	rows, err := m.selectRows(table, filter)
+	if err != nil {
+		return 0, err
+	}
 	schema, err := m.schema.GetSchema(table)
 	if err != nil {
 		return 0, err
 	}
+	wasInit := m.countInitialized(table)
 
 	count := 0
 	for _, row := range rows {
@@ -907,6 +1130,7 @@ func (m *TableManager) Delete(table string, filter func(Row) bool) (int, error)
 			return err
 		})
 		if err == nil {
+			m.bumpIndexPredicates(table, row)
 			count++
 		} else {
 			// Restore the index entries removed above.
@@ -914,21 +1138,31 @@ func (m *TableManager) Delete(table string, filter func(Row) bool) (int, error)
 		}
 	}
 
-	m.incrCount(table, schema.CreatedAt, -count)
+	if count > 0 {
+		m.bumpGeneration(table)
+	}
+	m.incrCount(table, schema.CreatedAt, -count, wasInit)
 	return count, nil
 }
 
-// DeleteByPK deletes one row without scanning the table.
+// DeleteByPK deletes one row without scanning the table, using the key's
+// striped lock and a compare-and-swap delete.
 func (m *TableManager) DeleteByPK(table, pk string) (Row, bool, error) {
-	tl := m.tableLock(table)
-	tl.Lock()
-	defer tl.Unlock()
-
 	schema, err := m.schema.GetSchema(table)
 	if err != nil {
 		return nil, false, err
 	}
-	row, err := m.getByPKUnlocked(table, pk)
+
+	key := m.dataKey(table, pk)
+	tl := m.tableLock(table)
+	tl.RLock()
+	defer tl.RUnlock()
+	st := m.stripeKey(key)
+	st.Lock()
+	defer st.Unlock()
+	wasInit := m.countInitialized(table)
+
+	row, lsn, err := m.getByPKWithLSN(table, pk)
 	if err == ErrKeyNotFound {
 		return nil, false, nil
 	}
@@ -936,34 +1170,46 @@ func (m *TableManager) DeleteByPK(table, pk string) (Row, bool, error) {
 		return nil, false, err
 	}
 
-	m.updateIndexesForRow(table, row, false)
-	err = m.pool.WithClient(func(client *KVClient) error {
-		_, err := client.Del([]byte(m.dataKey(table, pk)))
-		return err
-	})
+	committed, err := m.compareWritePoint(
+		[]CompareCheck{{Key: []byte(key), LSN: lsn}},
+		[]BatchOp{{Op: batchDelete, Key: []byte(key)}},
+	)
 	if err != nil {
-		m.updateIndexesForRow(table, row, true)
 		return nil, false, err
 	}
-	m.incrCount(table, schema.CreatedAt, -1)
+	if !committed {
+		return nil, false, ErrSerialization
+	}
+
+	m.updateIndexesForRow(table, row, false)
+	m.bumpIndexPredicates(table, row)
+	m.bumpGeneration(table)
+	m.incrCount(table, schema.CreatedAt, -1, wasInit)
 	return row, true, nil
 }
 
-// GetByPK retrieves a row by primary key.
+// GetByPK retrieves a row by primary key. Point reads take only the key's
+// striped lock so reads of different keys progress concurrently.
 func (m *TableManager) GetByPK(table string, pk string) (Row, error) {
-	tl := m.tableLock(table)
-	tl.RLock()
-	defer tl.RUnlock()
+	key := m.dataKey(table, pk)
+	st := m.stripeKey(key)
+	st.Lock()
+	defer st.Unlock()
 
 	if !m.schema.TableExists(table) {
 		return nil, fmt.Errorf("table not found: %s", table)
 	}
-	return m.getByPKUnlocked(table, pk)
+	row, _, err := m.getByPKWithLSN(table, pk)
+	return row, err
 }
 
-func (m *TableManager) getByPKUnlocked(table, pk string) (Row, error) {
+// getByPKWithLSN reads a row by primary key and returns its KV LSN (0 when
+// absent). It performs no locking; callers must hold the appropriate striped
+// or table lock.
+func (m *TableManager) getByPKWithLSN(table, pk string) (Row, uint64, error) {
 	key := m.dataKey(table, pk)
 	var value []byte
+	var lsn uint64
 
 	err := m.pool.WithClient(func(c *KVClient) error {
 		res, err := c.Get([]byte(key))
@@ -971,18 +1217,22 @@ func (m *TableManager) getByPKUnlocked(table, pk string) (Row, error) {
 			return err
 		}
 		value = res.Value
+		lsn = res.LSN
 		return nil
 	})
 	if err != nil {
-		return nil, err
+		if err == ErrKeyNotFound {
+			return nil, 0, ErrKeyNotFound
+		}
+		return nil, 0, err
 	}
 
 	row, err := decodeRow(value)
 	if err != nil {
-		return nil, fmt.Errorf("failed to parse row: %w", err)
+		return nil, 0, fmt.Errorf("failed to parse row: %w", err)
 	}
 
-	return row, nil
+	return row, lsn, nil
 }
 
 // Count returns the number of rows in a table matching the filter.
@@ -1308,16 +1558,28 @@ func (m *TableManager) buildIndexValue(row Row, columns []string) string {
 	return strings.Join(parts, "\x00")
 }
 
-// SelectByIndex retrieves rows using an index lookup. It obtains the matching
-// rowids from the in-memory index, then streams the table's rows and returns
-// only those whose rowid is indexed, without retaining a permanent row map.
-func (m *TableManager) SelectByIndex(table, indexName string, colValue interface{}) ([]Row, error) {
+type indexedRowVersion struct {
+	row Row
+	key string
+	lsn uint64
+}
+
+type indexPredicateSnapshot struct {
+	valueKey    string
+	valueGen    uint64
+	wildcardKey string
+	wildcardGen uint64
+}
+
+// selectByIndexWithLSN retrieves indexed rows and their durable versions. The
+// transaction layer uses the versions for optimistic commit validation.
+func (m *TableManager) selectByIndexWithLSN(table, indexName string, colValue interface{}) ([]indexedRowVersion, indexPredicateSnapshot, error) {
 	index, err := m.schema.GetIndex(indexName)
 	if err != nil {
-		return nil, err
+		return nil, indexPredicateSnapshot{}, err
 	}
 	if err := m.ensureIndex(index); err != nil {
-		return nil, err
+		return nil, indexPredicateSnapshot{}, err
 	}
 
 	tableKey := strings.ToLower(table)
@@ -1325,11 +1587,16 @@ func (m *TableManager) SelectByIndex(table, indexName string, colValue interface
 	tl.RLock()
 	defer tl.RUnlock()
 	if !m.schema.TableExists(table) {
-		return nil, fmt.Errorf("table not found: %s", table)
+		return nil, indexPredicateSnapshot{}, fmt.Errorf("table not found: %s", table)
 	}
 
 	indexKey := strings.ToLower(indexName)
 	valueKey := formatIndexValue(colValue)
+	predicateKey, predicateGen, wildcardKey, wildcardGen := m.predicateSnapshot(table, indexName, valueKey)
+	snapshot := indexPredicateSnapshot{
+		valueKey: predicateKey, valueGen: predicateGen,
+		wildcardKey: wildcardKey, wildcardGen: wildcardGen,
+	}
 	m.cacheMu.RLock()
 	rowids := append([]int64(nil), m.indexCache[indexKey][valueKey]...)
 	primaryKeys := make([]string, 0, len(rowids))
@@ -1346,15 +1613,15 @@ func (m *TableManager) SelectByIndex(table, indexName string, colValue interface
 	}
 	m.cacheMu.RUnlock()
 	if missingRowKey {
-		return nil, fmt.Errorf("index %s is missing rowid %d", indexName, missingRowID)
+		return nil, indexPredicateSnapshot{}, fmt.Errorf("index %s is missing rowid %d", indexName, missingRowID)
 	}
 
 	// If no rowids found, return empty result
 	if len(rowids) == 0 {
-		return []Row{}, nil
+		return []indexedRowVersion{}, snapshot, nil
 	}
 
-	rows := make([]Row, 0, len(primaryKeys))
+	rows := make([]indexedRowVersion, 0, len(primaryKeys))
 	err = m.pool.WithClient(func(client *KVClient) error {
 		keys := make([][]byte, len(primaryKeys))
 		for i, primaryKey := range primaryKeys {
@@ -1373,12 +1640,30 @@ func (m *TableManager) SelectByIndex(table, indexName string, colValue interface
 			if err != nil {
 				return err
 			}
-			rows = append(rows, row)
+			rows = append(rows, indexedRowVersion{
+				row: row,
+				key: m.dataKey(table, primaryKeys[i]),
+				lsn: result.LSN,
+			})
 		}
 		return nil
 	})
+	if err != nil {
+		return nil, indexPredicateSnapshot{}, err
+	}
+	return rows, snapshot, nil
+}
+
+// SelectByIndex retrieves rows using an in-memory equality index followed by a
+// single MultiGet for the matching primary keys.
+func (m *TableManager) SelectByIndex(table, indexName string, colValue interface{}) ([]Row, error) {
+	versions, _, err := m.selectByIndexWithLSN(table, indexName, colValue)
 	if err != nil {
 		return nil, err
 	}
+	rows := make([]Row, len(versions))
+	for i := range versions {
+		rows[i] = versions[i].row
+	}
 	return rows, nil
 }

+ 69 - 0
pkg/storage/table_test.go

@@ -56,6 +56,75 @@ func TestSelectWithLimitStopsBeforeAllPages(t *testing.T) {
 	}
 }
 
+func TestPointUpdateDoesNotBlockPointReadBehindTableWriter(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{
+		{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		{Name: "value", Type: "INTEGER"},
+	})
+	if err := tables.Insert("t", Row{"id": int64(1), "value": int64(1)}); err != nil {
+		t.Fatal(err)
+	}
+
+	gate := tables.tableLock("t")
+	gate.RLock()
+
+	writerDone := make(chan error, 1)
+	go func() {
+		_, err := tables.Delete("t", func(Row) bool { return false })
+		writerDone <- err
+	}()
+
+	deadline := time.Now().Add(time.Second)
+	for gate.TryRLock() {
+		gate.RUnlock()
+		if time.Now().After(deadline) {
+			gate.RUnlock()
+			t.Fatal("table writer did not queue")
+		}
+		time.Sleep(time.Millisecond)
+	}
+
+	updateDone := make(chan error, 1)
+	go func() {
+		_, _, err := tables.UpdateByPK("t", "1", func(Row) (Row, error) {
+			return Row{"value": int64(2)}, nil
+		})
+		updateDone <- err
+	}()
+
+	// Give the update time to reach the queued table gate. It must not hold the
+	// row stripe while waiting, or this point read completes only after timeout.
+	time.Sleep(10 * time.Millisecond)
+	readDone := make(chan error, 1)
+	go func() {
+		_, err := tables.GetByPK("t", "1")
+		readDone <- err
+	}()
+
+	select {
+	case err := <-readDone:
+		if err != nil {
+			gate.RUnlock()
+			t.Fatalf("point read: %v", err)
+		}
+	case <-time.After(250 * time.Millisecond):
+		gate.RUnlock()
+		<-writerDone
+		<-updateDone
+		<-readDone
+		t.Fatal("point read deadlocked behind queued table writer")
+	}
+
+	gate.RUnlock()
+	if err := <-writerDone; err != nil {
+		t.Fatalf("table writer: %v", err)
+	}
+	if err := <-updateDone; err != nil {
+		t.Fatalf("point update: %v", err)
+	}
+}
+
 // TestDropTableDeletesDurableRows verifies that a direct DropTable removes all
 // durable row keys, not just the schema entry.
 func TestDropTableDeletesDurableRows(t *testing.T) {

+ 697 - 0
pkg/storage/tx.go

@@ -0,0 +1,697 @@
+package storage
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+	"sync"
+)
+
+// ErrSerialization is returned when a transaction's optimistic validation fails
+// because a concurrent transaction committed a conflicting change.
+var ErrSerialization = fmt.Errorf("serialization failure: concurrent transaction modified the database")
+
+// Session is a buffered SQL transaction/session wrapper around a TableManager.
+// While in a transaction, writes are staged in memory, reads observe that
+// staged overlay, and COMMIT issues a single atomic compare-and-swap batch
+// write. Rollback simply discards the staged changes (no compensating durable
+// writes). A Session is used for both autocommit statements (where operations
+// delegate straight to the durable TableManager) and buffered transactions.
+type Session struct {
+	schema *SchemaManager
+	table  *TableManager
+
+	mu sync.Mutex
+
+	inTx    bool
+	aborted bool
+
+	// overlay holds staged writes keyed by lowercased table then data key.
+	overlay map[string]map[string]*overlayEntry
+
+	// log records mutations in order so savepoints can roll back.
+	log []txMutation
+
+	// reads records the durable LSN of every key the transaction observed
+	// (0 means the key was observed absent). It becomes the compare set at
+	// commit and also captures the base LSN of every written key.
+	reads map[string]uint64
+
+	// scanGens records the per-table generation captured by the first scan of
+	// each table, validated at commit to detect phantoms. Indexed equality reads
+	// use predicateGens so writes to other index values do not cause conflicts.
+	scanGens      map[string]uint64
+	predicateGens map[string]predicateRead
+}
+
+type predicateRead struct {
+	table string
+	gen   uint64
+}
+
+type overlayEntry struct {
+	row    Row
+	absent bool
+}
+
+type txMutation struct {
+	table string
+	key   string
+	prev  *overlayEntry
+}
+
+// NewSession creates a session wrapping the given schema and table managers.
+func NewSession(schema *SchemaManager, table *TableManager) *Session {
+	return &Session{
+		schema:        schema,
+		table:         table,
+		overlay:       make(map[string]map[string]*overlayEntry),
+		reads:         make(map[string]uint64),
+		scanGens:      make(map[string]uint64),
+		predicateGens: make(map[string]predicateRead),
+	}
+}
+
+// Begin starts a buffered transaction.
+func (s *Session) Begin() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.inTx {
+		return fmt.Errorf("cannot start a transaction within a transaction")
+	}
+	s.inTx = true
+	s.aborted = false
+	s.overlay = make(map[string]map[string]*overlayEntry)
+	s.log = nil
+	s.reads = make(map[string]uint64)
+	s.scanGens = make(map[string]uint64)
+	s.predicateGens = make(map[string]predicateRead)
+	return nil
+}
+
+// InTx reports whether a transaction is in progress.
+func (s *Session) InTx() bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.inTx
+}
+
+// Abort marks the transaction as aborted without discarding state.
+func (s *Session) Abort() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.inTx {
+		s.aborted = true
+	}
+}
+
+// Snapshot returns the current mutation-log position for a savepoint.
+func (s *Session) Snapshot() int {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return len(s.log)
+}
+
+// RollbackTo discards mutations after the given savepoint position.
+func (s *Session) RollbackTo(pos int) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	for i := len(s.log) - 1; i >= pos && i >= 0; i-- {
+		m := s.log[i]
+		if m.prev == nil {
+			delete(s.overlay[m.table], m.key)
+			if len(s.overlay[m.table]) == 0 {
+				delete(s.overlay, m.table)
+			}
+		} else {
+			if s.overlay[m.table] == nil {
+				s.overlay[m.table] = make(map[string]*overlayEntry)
+			}
+			s.overlay[m.table][m.key] = m.prev
+		}
+	}
+	if pos < len(s.log) {
+		s.log = s.log[:pos]
+	}
+	s.aborted = false
+}
+
+// Rollback discards the transaction without writing anything durable.
+func (s *Session) Rollback() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.inTx {
+		return fmt.Errorf("cannot rollback: no transaction in progress")
+	}
+	s.resetLocked()
+	return nil
+}
+
+// Commit validates and durably applies the staged transaction in one atomic
+// compare-and-swap batch write.
+func (s *Session) Commit() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.inTx {
+		return fmt.Errorf("cannot commit: no transaction in progress")
+	}
+	if s.aborted {
+		s.resetLocked()
+		return fmt.Errorf("current transaction is aborted")
+	}
+
+	// Collect affected tables and lock them in sorted order. Tables read through
+	// scans or predicates need an exclusive validation gate. Tables that are
+	// only written use the shared publication gate, allowing disjoint optimistic
+	// commits to proceed concurrently while still excluding scanner commits.
+	affected := make(map[string]bool)
+	for t := range s.overlay {
+		affected[t] = false
+	}
+	for t := range s.scanGens {
+		affected[t] = true
+	}
+	for _, predicate := range s.predicateGens {
+		affected[predicate.table] = true
+	}
+	tables := make([]string, 0, len(affected))
+	for t := range affected {
+		tables = append(tables, t)
+	}
+	sort.Strings(tables)
+
+	locks := make([]*sync.RWMutex, len(tables))
+	exclusive := make([]bool, len(tables))
+	for i, t := range tables {
+		locks[i] = s.table.tableLock(t)
+		exclusive[i] = affected[t]
+	}
+	for i, lock := range locks {
+		if exclusive[i] {
+			lock.Lock()
+		} else {
+			lock.RLock()
+		}
+	}
+	defer func() {
+		for i := len(locks) - 1; i >= 0; i-- {
+			if exclusive[i] {
+				locks[i].Unlock()
+			} else {
+				locks[i].RUnlock()
+			}
+		}
+	}()
+
+	// Validate scan generations for phantom detection.
+	for t, gen := range s.scanGens {
+		if s.table.generation(t) != gen {
+			s.resetLocked()
+			return ErrSerialization
+		}
+	}
+	for key, predicate := range s.predicateGens {
+		if s.table.predicateGeneration(key) != predicate.gen {
+			s.resetLocked()
+			return ErrSerialization
+		}
+	}
+
+	// Build the compare set from every observed key.
+	checks := make([]CompareCheck, 0, len(s.reads))
+	for key, lsn := range s.reads {
+		checks = append(checks, CompareCheck{Key: []byte(key), LSN: lsn})
+	}
+	sort.Slice(checks, func(i, j int) bool { return string(checks[i].Key) < string(checks[j].Key) })
+
+	// Build the batch ops from the staged overlay.
+	ops := make([]BatchOp, 0)
+	for _, entries := range s.overlay {
+		for key, e := range entries {
+			if e.absent {
+				ops = append(ops, BatchOp{Op: batchDelete, Key: []byte(key)})
+			} else {
+				data, err := encodeRow(e.row)
+				if err != nil {
+					return err
+				}
+				ops = append(ops, BatchOp{Op: batchPut, Key: []byte(key), Value: data})
+			}
+		}
+	}
+	sort.Slice(ops, func(i, j int) bool { return string(ops[i].Key) < string(ops[j].Key) })
+
+	// A read-only transaction has nothing to write; commit trivially.
+	if len(ops) == 0 {
+		s.resetLocked()
+		return nil
+	}
+
+	var committed bool
+	err := s.table.pool.WithClient(func(c *KVClient) error {
+		_, ok, err := c.CompareBatchWrite(checks, ops, nil)
+		committed = ok
+		return err
+	})
+	if err != nil {
+		return err
+	}
+	if !committed {
+		s.resetLocked()
+		return ErrSerialization
+	}
+
+	// Advance generations and invalidate derived caches for written tables.
+	for t := range s.overlay {
+		s.table.InvalidateCache(t)
+		s.table.bumpIndexPredicateWildcard(t)
+		s.table.bumpGeneration(t)
+	}
+
+	s.resetLocked()
+	return nil
+}
+
+func (s *Session) resetLocked() {
+	s.inTx = false
+	s.aborted = false
+	s.overlay = make(map[string]map[string]*overlayEntry)
+	s.log = nil
+	s.reads = make(map[string]uint64)
+	s.scanGens = make(map[string]uint64)
+	s.predicateGens = make(map[string]predicateRead)
+}
+
+func (s *Session) stagePut(table, key string, row Row) {
+	tl := strings.ToLower(table)
+	if s.overlay[tl] == nil {
+		s.overlay[tl] = make(map[string]*overlayEntry)
+	}
+	s.log = append(s.log, txMutation{table: tl, key: key, prev: s.overlay[tl][key]})
+	s.overlay[tl][key] = &overlayEntry{row: cloneRow(row)}
+	if _, ok := s.reads[key]; !ok {
+		s.reads[key] = 0
+	}
+}
+
+func (s *Session) stageDelete(table, key string) {
+	tl := strings.ToLower(table)
+	if s.overlay[tl] == nil {
+		s.overlay[tl] = make(map[string]*overlayEntry)
+	}
+	s.log = append(s.log, txMutation{table: tl, key: key, prev: s.overlay[tl][key]})
+	s.overlay[tl][key] = &overlayEntry{absent: true}
+	if _, ok := s.reads[key]; !ok {
+		s.reads[key] = 0
+	}
+}
+
+// GetByPK retrieves a row by primary key, observing the staged overlay in a
+// transaction and recording the observed LSN for validation.
+func (s *Session) GetByPK(table, pk string) (Row, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.inTx {
+		return s.table.GetByPK(table, pk)
+	}
+	key := s.table.dataKey(table, pk)
+	tl := strings.ToLower(table)
+	if e, ok := s.overlay[tl][key]; ok {
+		if e.absent {
+			return nil, ErrKeyNotFound
+		}
+		return cloneRow(e.row), nil
+	}
+	row, lsn, err := s.table.getByPKWithLSN(table, pk)
+	if err != nil {
+		if err == ErrKeyNotFound {
+			s.reads[key] = 0
+			return nil, ErrKeyNotFound
+		}
+		return nil, err
+	}
+	s.reads[key] = lsn
+	return row, nil
+}
+
+// Select scans a table, merging the staged overlay so a transaction sees its
+// own writes, and records per-row LSNs plus the table generation.
+func (s *Session) Select(table string, filter func(Row) bool) ([]Row, error) {
+	s.mu.Lock()
+	if !s.inTx {
+		s.mu.Unlock()
+		return s.table.Select(table, filter)
+	}
+	defer s.mu.Unlock()
+	return s.selectLocked(table, filter)
+}
+
+func (s *Session) selectLocked(table string, filter func(Row) bool) ([]Row, error) {
+	schema, err := s.schema.GetSchema(table)
+	if err != nil {
+		return nil, err
+	}
+	tl := strings.ToLower(table)
+	tableLock := s.table.tableLock(tl)
+	tableLock.RLock()
+	defer tableLock.RUnlock()
+	if _, ok := s.scanGens[tl]; !ok {
+		s.scanGens[tl] = s.table.generation(tl)
+	}
+	overlay := s.overlay[tl]
+
+	var rows []Row
+	err = s.table.scanRowsWithLSN(table, func(row Row, lsn uint64) (bool, error) {
+		key := s.table.dataKey(table, fmt.Sprintf("%v", row[schema.PrimaryKey]))
+		if _, ok := overlay[key]; ok {
+			return false, nil
+		}
+		s.reads[key] = lsn
+		if filter == nil || filter(row) {
+			rows = append(rows, row)
+		}
+		return false, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	for _, e := range overlay {
+		if e.absent {
+			continue
+		}
+		if filter == nil || filter(e.row) {
+			rows = append(rows, cloneRow(e.row))
+		}
+	}
+	return rows, nil
+}
+
+// SelectByIndex reads only matching durable rows while capturing their LSNs,
+// then merges the transaction overlay. The table generation protects against
+// matching rows being inserted or removed after the lookup.
+func (s *Session) SelectByIndex(table, indexName string, colValue interface{}) ([]Row, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.inTx {
+		return s.table.SelectByIndex(table, indexName, colValue)
+	}
+	tableKey := strings.ToLower(table)
+	index, err := s.schema.GetIndex(indexName)
+	if err != nil {
+		return nil, err
+	}
+	col := index.Columns[0].Name
+	want := formatIndexValue(colValue)
+	versions, predicate, err := s.table.selectByIndexWithLSN(table, indexName, colValue)
+	if err != nil {
+		return nil, err
+	}
+	if _, ok := s.predicateGens[predicate.valueKey]; !ok {
+		s.predicateGens[predicate.valueKey] = predicateRead{table: tableKey, gen: predicate.valueGen}
+	}
+	if _, ok := s.predicateGens[predicate.wildcardKey]; !ok {
+		s.predicateGens[predicate.wildcardKey] = predicateRead{table: tableKey, gen: predicate.wildcardGen}
+	}
+	overlay := s.overlay[tableKey]
+	rows := make([]Row, 0, len(versions)+len(overlay))
+	for _, version := range versions {
+		if _, staged := overlay[version.key]; staged {
+			continue
+		}
+		s.reads[version.key] = version.lsn
+		rows = append(rows, version.row)
+	}
+	for _, entry := range overlay {
+		if !entry.absent && formatIndexValue(entry.row[col]) == want {
+			rows = append(rows, cloneRow(entry.row))
+		}
+	}
+	return rows, nil
+}
+
+// CountFast returns the exact row count, observing the staged overlay in a
+// transaction.
+func (s *Session) CountFast(table string) (int, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.inTx {
+		return s.table.CountFast(table)
+	}
+	rows, err := s.selectLocked(table, nil)
+	if err != nil {
+		return 0, err
+	}
+	return len(rows), nil
+}
+
+// Insert stages an insert in a transaction, or performs a durable autocommit
+// insert otherwise.
+func (s *Session) Insert(table string, row Row) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.inTx {
+		return s.table.Insert(table, row)
+	}
+
+	nr, key, err := s.table.prepareInsert(table, row)
+	if err != nil {
+		return err
+	}
+	schema, err := s.schema.GetSchema(table)
+	if err != nil {
+		return err
+	}
+	pk := fmt.Sprintf("%v", nr[schema.PrimaryKey])
+	tl := strings.ToLower(table)
+
+	if e, ok := s.overlay[tl][key]; ok {
+		if !e.absent {
+			return fmt.Errorf("duplicate primary key: %s", pk)
+		}
+	} else {
+		_, lsn, err := s.table.getByPKWithLSN(table, pk)
+		if err == nil {
+			s.reads[key] = lsn
+			return fmt.Errorf("duplicate primary key: %s", pk)
+		}
+		if err != ErrKeyNotFound {
+			return err
+		}
+		s.reads[key] = 0
+	}
+
+	s.stagePut(table, key, nr)
+	return nil
+}
+
+// InsertBulk stages or durably bulk-inserts multiple rows.
+func (s *Session) InsertBulk(table string, rows []Row) (int, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.inTx {
+		return s.table.InsertBulk(table, rows)
+	}
+	count := 0
+	for _, row := range rows {
+		if err := s.insertLocked(table, row); err != nil {
+			return count, err
+		}
+		count++
+	}
+	return count, nil
+}
+
+// insertLocked is the transaction insert helper (caller holds s.mu).
+func (s *Session) insertLocked(table string, row Row) error {
+	nr, key, err := s.table.prepareInsert(table, row)
+	if err != nil {
+		return err
+	}
+	schema, err := s.schema.GetSchema(table)
+	if err != nil {
+		return err
+	}
+	pk := fmt.Sprintf("%v", nr[schema.PrimaryKey])
+	tl := strings.ToLower(table)
+
+	if e, ok := s.overlay[tl][key]; ok {
+		if !e.absent {
+			return fmt.Errorf("duplicate primary key: %s", pk)
+		}
+	} else {
+		_, lsn, err := s.table.getByPKWithLSN(table, pk)
+		if err == nil {
+			s.reads[key] = lsn
+			return fmt.Errorf("duplicate primary key: %s", pk)
+		}
+		if err != ErrKeyNotFound {
+			return err
+		}
+		s.reads[key] = 0
+	}
+
+	s.stagePut(table, key, nr)
+	return nil
+}
+
+// UpdateByPK stages or durably applies a single-row update.
+func (s *Session) UpdateByPK(table, pk string, updateFn func(Row) (Row, error)) (Row, bool, error) {
+	s.mu.Lock()
+	if !s.inTx {
+		s.mu.Unlock()
+		return s.table.UpdateByPK(table, pk, updateFn)
+	}
+	defer s.mu.Unlock()
+
+	schema, err := s.schema.GetSchema(table)
+	if err != nil {
+		return nil, false, err
+	}
+	key := s.table.dataKey(table, pk)
+
+	row, err := s.getByPKLocked(table, pk)
+	if err == ErrKeyNotFound {
+		return nil, false, nil
+	}
+	if err != nil {
+		return nil, false, err
+	}
+
+	oldRow := cloneRow(row)
+	updates, err := updateFn(row)
+	if err != nil {
+		return nil, false, err
+	}
+	for name, value := range updates {
+		for _, column := range schema.Columns {
+			if strings.EqualFold(name, column.Name) {
+				row[column.Name] = value
+				break
+			}
+		}
+	}
+
+	s.stagePut(table, key, row)
+	return oldRow, true, nil
+}
+
+// DeleteByPK stages or durably applies a single-row delete.
+func (s *Session) DeleteByPK(table, pk string) (Row, bool, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.inTx {
+		return s.table.DeleteByPK(table, pk)
+	}
+
+	key := s.table.dataKey(table, pk)
+	row, err := s.getByPKLocked(table, pk)
+	if err == ErrKeyNotFound {
+		return nil, false, nil
+	}
+	if err != nil {
+		return nil, false, err
+	}
+	s.stageDelete(table, key)
+	return row, true, nil
+}
+
+// getByPKLocked reads a row observing the overlay (caller holds s.mu).
+func (s *Session) getByPKLocked(table, pk string) (Row, error) {
+	key := s.table.dataKey(table, pk)
+	tl := strings.ToLower(table)
+	if e, ok := s.overlay[tl][key]; ok {
+		if e.absent {
+			return nil, ErrKeyNotFound
+		}
+		return cloneRow(e.row), nil
+	}
+	row, lsn, err := s.table.getByPKWithLSN(table, pk)
+	if err != nil {
+		if err == ErrKeyNotFound {
+			s.reads[key] = 0
+			return nil, ErrKeyNotFound
+		}
+		return nil, err
+	}
+	s.reads[key] = lsn
+	return row, nil
+}
+
+// UpdateFunc stages or durably applies a scan-based update.
+func (s *Session) UpdateFunc(table string, updateFn func(Row) (Row, error), filter func(Row) bool) (int, error) {
+	s.mu.Lock()
+	if !s.inTx {
+		s.mu.Unlock()
+		return s.table.UpdateFunc(table, updateFn, filter)
+	}
+	defer s.mu.Unlock()
+
+	schema, err := s.schema.GetSchema(table)
+	if err != nil {
+		return 0, err
+	}
+	rows, err := s.selectLocked(table, filter)
+	if err != nil {
+		return 0, err
+	}
+	count := 0
+	for _, row := range rows {
+		updates, err := updateFn(row)
+		if err != nil {
+			return count, err
+		}
+		for name, value := range updates {
+			for _, column := range schema.Columns {
+				if strings.EqualFold(name, column.Name) {
+					row[column.Name] = value
+					break
+				}
+			}
+		}
+		key := s.table.dataKey(table, fmt.Sprintf("%v", row[schema.PrimaryKey]))
+		s.stagePut(table, key, row)
+		count++
+	}
+	return count, nil
+}
+
+// Delete stages or durably applies a scan-based delete.
+func (s *Session) Delete(table string, filter func(Row) bool) (int, error) {
+	s.mu.Lock()
+	if !s.inTx {
+		s.mu.Unlock()
+		return s.table.Delete(table, filter)
+	}
+	defer s.mu.Unlock()
+
+	schema, err := s.schema.GetSchema(table)
+	if err != nil {
+		return 0, err
+	}
+	rows, err := s.selectLocked(table, filter)
+	if err != nil {
+		return 0, err
+	}
+	count := 0
+	for _, row := range rows {
+		key := s.table.dataKey(table, fmt.Sprintf("%v", row[schema.PrimaryKey]))
+		s.stageDelete(table, key)
+		count++
+	}
+	return count, nil
+}
+
+// ClearIndex passes through to the durable table manager.
+func (s *Session) ClearIndex(indexName, tableName string, columns []string) error {
+	return s.table.ClearIndex(indexName, tableName, columns)
+}
+
+// InvalidateCache passes through to the durable table manager.
+func (s *Session) InvalidateCache(table string) {
+	s.table.InvalidateCache(table)
+}
+
+// BuildIndex passes through to the durable table manager.
+func (s *Session) BuildIndex(indexName, tableName string, columns []string) error {
+	return s.table.BuildIndex(indexName, tableName, columns)
+}

+ 444 - 0
pkg/storage/tx_test.go

@@ -0,0 +1,444 @@
+package storage
+
+import (
+	"fmt"
+	"sync"
+	"testing"
+	"time"
+)
+
+func newTestSession(t *testing.T) (*testKVServer, *KVPool, *SchemaManager, *TableManager) {
+	t.Helper()
+	kv := newTestKVServer(t)
+	pool := newTestKVPool(kv, 8, 5*time.Second)
+	schemas := NewSchemaManager(pool, "testdb")
+	tables := NewTableManager(pool, schemas, "testdb")
+	t.Cleanup(func() { pool.Close() })
+	return kv, pool, schemas, tables
+}
+
+func createTestTable(t *testing.T, schemas *SchemaManager, name string, cols []Column) {
+	t.Helper()
+	if err := schemas.CreateTable(&Schema{Name: name, Columns: cols}); err != nil {
+		t.Fatalf("create table %s: %v", name, err)
+	}
+}
+
+func TestSessionReadYourWrites(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}, {Name: "v", Type: "TEXT"}})
+
+	s := NewSession(schemas, tables)
+	if err := s.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Insert("t", Row{"id": int64(1), "v": "one"}); err != nil {
+		t.Fatal(err)
+	}
+	// Point read observes the staged overlay before commit.
+	row, err := s.GetByPK("t", "1")
+	if err != nil || row["v"] != "one" {
+		t.Fatalf("read-your-writes GetByPK: row=%v err=%v", row, err)
+	}
+	// Scan observes the staged overlay before commit.
+	rows, err := s.Select("t", nil)
+	if err != nil || len(rows) != 1 || rows[0]["v"] != "one" {
+		t.Fatalf("read-your-writes Select: rows=%v err=%v", rows, err)
+	}
+	if err := s.Commit(); err != nil {
+		t.Fatal(err)
+	}
+	// Still visible from a fresh read after commit.
+	if row, err := tables.GetByPK("t", "1"); err != nil || row["v"] != "one" {
+		t.Fatalf("post-commit GetByPK: row=%v err=%v", row, err)
+	}
+}
+
+func TestSessionIndexedReadTracksOnlyMatches(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "items", []Column{
+		{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		{Name: "cart_id", Type: "TEXT"},
+	})
+	if err := schemas.CreateIndex(&Index{
+		Name: "idx_items_cart", Table: "items",
+		Columns: []IndexColumn{{Name: "cart_id"}},
+	}); err != nil {
+		t.Fatal(err)
+	}
+	rows := make([]Row, 50)
+	for i := range rows {
+		rows[i] = Row{"id": int64(i + 1), "cart_id": fmt.Sprintf("cart-%d", i)}
+	}
+	if _, err := tables.InsertBulk("items", rows); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.BuildIndex("idx_items_cart", "items", []string{"cart_id"}); err != nil {
+		t.Fatal(err)
+	}
+
+	s := NewSession(schemas, tables)
+	if err := s.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	got, err := s.SelectByIndex("items", "idx_items_cart", "cart-37")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(got) != 1 || got[0]["id"] != int64(38) {
+		t.Fatalf("indexed rows = %#v", got)
+	}
+	if len(s.reads) != 1 {
+		t.Fatalf("indexed transaction captured %d row versions, want 1", len(s.reads))
+	}
+	if err := s.Rollback(); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestSessionIndexedPredicateConflictsOnlyOnMatchingValue(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "items", []Column{
+		{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		{Name: "cart_id", Type: "TEXT"},
+	})
+	if err := schemas.CreateIndex(&Index{
+		Name: "idx_items_cart", Table: "items",
+		Columns: []IndexColumn{{Name: "cart_id"}},
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("items", Row{"id": int64(1), "cart_id": "cart-a"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.BuildIndex("idx_items_cart", "items", []string{"cart_id"}); err != nil {
+		t.Fatal(err)
+	}
+
+	unrelated := NewSession(schemas, tables)
+	if err := unrelated.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := unrelated.SelectByIndex("items", "idx_items_cart", "cart-a"); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("items", Row{"id": int64(2), "cart_id": "cart-b"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := unrelated.Commit(); err != nil {
+		t.Fatalf("unrelated indexed insert caused conflict: %v", err)
+	}
+
+	matching := NewSession(schemas, tables)
+	if err := matching.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := matching.SelectByIndex("items", "idx_items_cart", "cart-c"); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("items", Row{"id": int64(3), "cart_id": "cart-c"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := matching.Commit(); err != ErrSerialization {
+		t.Fatalf("matching indexed insert commit error = %v, want ErrSerialization", err)
+	}
+}
+
+func TestSessionRollbackZeroDurableWrites(t *testing.T) {
+	kv, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}})
+
+	s := NewSession(schemas, tables)
+	if err := s.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Insert("t", Row{"id": int64(1)}); err != nil {
+		t.Fatal(err)
+	}
+	if _, deleted, err := s.DeleteByPK("t", "1"); err != nil || !deleted {
+		t.Fatalf("delete staged row: deleted=%v err=%v", deleted, err)
+	}
+	if err := s.Insert("t", Row{"id": int64(2)}); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Rollback(); err != nil {
+		t.Fatal(err)
+	}
+	if got := kv.countKeys("testdb:_data:t:"); got != 0 {
+		t.Fatalf("rollback left %d durable rows", got)
+	}
+}
+
+func TestSessionSavepoints(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}})
+
+	s := NewSession(schemas, tables)
+	if err := s.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Insert("t", Row{"id": int64(1)}); err != nil {
+		t.Fatal(err)
+	}
+	sp := s.Snapshot()
+	if err := s.Insert("t", Row{"id": int64(2)}); err != nil {
+		t.Fatal(err)
+	}
+	s.RollbackTo(sp)
+	if err := s.Commit(); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := tables.GetByPK("t", "1"); err != nil {
+		t.Fatalf("row 1 should be committed: %v", err)
+	}
+	if _, err := tables.GetByPK("t", "2"); err != ErrKeyNotFound {
+		t.Fatalf("row 2 should be discarded, err=%v", err)
+	}
+}
+
+func TestSessionAtomicMultiTableCommit(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "a", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}})
+	createTestTable(t, schemas, "b", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}})
+
+	s1 := NewSession(schemas, tables)
+	s2 := NewSession(schemas, tables)
+
+	if err := s1.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s2.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s1.Insert("a", Row{"id": int64(1)}); err != nil {
+		t.Fatal(err)
+	}
+	if err := s1.Insert("b", Row{"id": int64(1)}); err != nil {
+		t.Fatal(err)
+	}
+	if err := s2.Insert("a", Row{"id": int64(1)}); err != nil {
+		t.Fatal(err)
+	}
+	if err := s2.Insert("b", Row{"id": int64(2)}); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := s1.Commit(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s2.Commit(); err != ErrSerialization {
+		t.Fatalf("expected s2 commit to conflict, got %v", err)
+	}
+
+	// s1 committed both rows; s2 committed neither (atomic rollback).
+	if _, err := tables.GetByPK("a", "1"); err != nil {
+		t.Fatalf("a/1 missing: %v", err)
+	}
+	if _, err := tables.GetByPK("b", "1"); err != nil {
+		t.Fatalf("b/1 missing: %v", err)
+	}
+	if _, err := tables.GetByPK("b", "2"); err != ErrKeyNotFound {
+		t.Fatalf("b/2 should be absent after s2 conflict, err=%v", err)
+	}
+}
+
+func TestSessionConflictingUpdateExactlyOneCommits(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "acct", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}, {Name: "bal", Type: "INTEGER"}})
+	if err := tables.Insert("acct", Row{"id": int64(1), "bal": int64(100)}); err != nil {
+		t.Fatal(err)
+	}
+
+	s1 := NewSession(schemas, tables)
+	s2 := NewSession(schemas, tables)
+
+	update := func(s *Session) (func() error, error) {
+		if err := s.Begin(); err != nil {
+			return nil, err
+		}
+		row, err := s.GetByPK("acct", "1")
+		if err != nil {
+			return nil, err
+		}
+		if _, _, err := s.UpdateByPK("acct", "1", func(Row) (Row, error) {
+			return Row{"bal": row["bal"].(int64) + 10}, nil
+		}); err != nil {
+			return nil, err
+		}
+		return s.Commit, nil
+	}
+
+	c1, err := update(s1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	c2, err := update(s2)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	errs := make([]error, 2)
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() { defer wg.Done(); errs[0] = c1() }()
+	go func() { defer wg.Done(); errs[1] = c2() }()
+	wg.Wait()
+
+	ok, conflict := 0, 0
+	for _, e := range errs {
+		if e == nil {
+			ok++
+		} else if e == ErrSerialization {
+			conflict++
+		} else {
+			t.Fatalf("unexpected commit error: %v", e)
+		}
+	}
+	if ok != 1 || conflict != 1 {
+		t.Fatalf("expected exactly one commit and one conflict, got ok=%d conflict=%d", ok, conflict)
+	}
+	row, err := tables.GetByPK("acct", "1")
+	if err != nil || row["bal"] != int64(110) {
+		t.Fatalf("balance should be 110, got %v err=%v", row, err)
+	}
+}
+
+func TestSessionNonConflictingConcurrentTransactions(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}, {Name: "v", Type: "TEXT"}})
+
+	s1 := NewSession(schemas, tables)
+	s2 := NewSession(schemas, tables)
+	if err := s1.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s2.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s1.Insert("t", Row{"id": int64(1), "v": "a"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := s2.Insert("t", Row{"id": int64(2), "v": "b"}); err != nil {
+		t.Fatal(err)
+	}
+
+	var wg sync.WaitGroup
+	errs := make([]error, 2)
+	wg.Add(2)
+	go func() { defer wg.Done(); errs[0] = s1.Commit() }()
+	go func() { defer wg.Done(); errs[1] = s2.Commit() }()
+	wg.Wait()
+	for i, e := range errs {
+		if e != nil {
+			t.Fatalf("commit %d failed: %v", i, e)
+		}
+	}
+	if rows, err := tables.Select("t", nil); err != nil || len(rows) != 2 {
+		t.Fatalf("expected 2 rows, got %d err=%v", len(rows), err)
+	}
+}
+
+func TestDuplicateInsertConcurrent(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}})
+
+	const n = 32
+	errs := make([]error, n)
+	var wg sync.WaitGroup
+	for i := 0; i < n; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			errs[i] = tables.Insert("t", Row{"id": int64(1)})
+		}()
+	}
+	wg.Wait()
+	ok := 0
+	for _, e := range errs {
+		if e == nil {
+			ok++
+		} else if !(e != nil && fmt.Sprintf("%s", e) == "duplicate primary key: 1") {
+			t.Fatalf("unexpected insert error: %v", e)
+		}
+	}
+	if ok != 1 {
+		t.Fatalf("expected exactly one successful insert, got %d", ok)
+	}
+	if got := kvCount(t, tables, "t"); got != 1 {
+		t.Fatalf("expected 1 row, got %d", got)
+	}
+}
+
+func kvCount(t *testing.T, tables *TableManager, table string) int {
+	t.Helper()
+	n, err := tables.CountFast(table)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return n
+}
+
+func TestPerTableConcurrentRowIDs(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{{Name: "name", Type: "TEXT"}}) // _rowid_ PK
+
+	const n = 100
+	var wg sync.WaitGroup
+	errCh := make(chan error, n)
+	for i := 0; i < n; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			if err := tables.Insert("t", Row{"name": fmt.Sprintf("n%d", i)}); err != nil {
+				errCh <- fmt.Errorf("insert %d: %v", i, err)
+			}
+		}(i)
+	}
+	wg.Wait()
+	close(errCh)
+	for err := range errCh {
+		t.Fatal(err)
+	}
+
+	if rows, err := tables.Select("t", nil); err != nil || len(rows) != n {
+		t.Fatalf("expected %d rows, got %d err=%v", n, len(rows), err)
+	}
+}
+
+func TestPointOpsDifferentKeysProgressConcurrently(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{{Name: "id", Type: "TEXT", PrimaryKey: true}, {Name: "v", Type: "INTEGER"}})
+
+	const n = 50
+	var wg sync.WaitGroup
+	errCh := make(chan error, n*3)
+	for i := 0; i < n; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			key := fmt.Sprintf("k%d", i)
+			if err := tables.Insert("t", Row{"id": key, "v": int64(i)}); err != nil {
+				errCh <- fmt.Errorf("insert %s: %v", key, err)
+				return
+			}
+			if _, _, err := tables.UpdateByPK("t", key, func(Row) (Row, error) { return Row{"v": int64(i + 100)}, nil }); err != nil {
+				errCh <- fmt.Errorf("update %s: %v", key, err)
+				return
+			}
+			if _, err := tables.GetByPK("t", key); err != nil {
+				errCh <- fmt.Errorf("get %s: %v", key, err)
+			}
+		}(i)
+	}
+	done := make(chan struct{})
+	go func() { wg.Wait(); close(done) }()
+	select {
+	case <-done:
+	case <-time.After(10 * time.Second):
+		t.Fatal("point operations on distinct keys did not progress concurrently")
+	}
+	close(errCh)
+	for err := range errCh {
+		t.Fatal(err)
+	}
+}

+ 591 - 0
pkg/testkv/testkv.go

@@ -0,0 +1,591 @@
+// Package testkv provides an in-memory PizzaKV-compatible server for tests. It
+// speaks the PKBFI wire protocol over a real TCP listener so production
+// KVPool/KVClient code paths can be exercised without a separate binary.
+package testkv
+
+import (
+	"bufio"
+	"hash/crc32"
+	"io"
+	"net"
+	"sort"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+const (
+	headerMagic   = "PKBF"
+	headerVersion = 1
+	headerSize    = 32
+
+	opPing         = 1
+	opGet          = 3
+	opPut          = 4
+	opDelete       = 5
+	opExists       = 6
+	opMultiGet     = 7
+	opBatchWrite   = 8
+	opScanOpen     = 9
+	opScanNext     = 10
+	opScanClose    = 11
+	opCompareBatch = 12
+
+	batchPut    = 1
+	batchDelete = 2
+
+	statusOK       = 0
+	statusNotFound = 1
+	statusError    = 2
+)
+
+var crcTable = crc32.MakeTable(crc32.Castagnoli)
+
+type scan struct {
+	keys     []string
+	offset   int
+	limit    uint32
+	keysOnly bool
+}
+
+// Server is an in-memory KV server.
+type Server struct {
+	mu      sync.Mutex
+	data    map[string][]byte
+	lsns    map[string]uint64
+	nextLSN uint64
+
+	ln       net.Listener
+	wg       sync.WaitGroup
+	closed   bool
+	scans    map[uint64]*scan
+	nextScan uint64
+}
+
+// New starts an in-memory KV server on an ephemeral TCP port.
+func New(t testing.TB) *Server {
+	t.Helper()
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	s := &Server{
+		data:  make(map[string][]byte),
+		lsns:  make(map[string]uint64),
+		ln:    ln,
+		scans: make(map[uint64]*scan),
+	}
+	s.wg.Add(1)
+	go s.acceptLoop()
+	t.Cleanup(func() { s.Close() })
+	return s
+}
+
+// Addr returns the server's listen address.
+func (s *Server) Addr() string { return s.ln.Addr().String() }
+
+// Pool creates a KV pool connected to the server.
+func (s *Server) Pool(size int) *storage.KVPool {
+	pool, err := storage.NewKVPool(s.Addr(), size, 5*time.Second)
+	if err != nil {
+		panic(err)
+	}
+	return pool
+}
+
+// Close stops the server.
+func (s *Server) Close() {
+	s.mu.Lock()
+	if s.closed {
+		s.mu.Unlock()
+		return
+	}
+	s.closed = true
+	s.mu.Unlock()
+	_ = s.ln.Close()
+	s.wg.Wait()
+}
+
+func (s *Server) acceptLoop() {
+	defer s.wg.Done()
+	for {
+		conn, err := s.ln.Accept()
+		if err != nil {
+			return
+		}
+		s.wg.Add(1)
+		go func() {
+			defer s.wg.Done()
+			s.handle(conn)
+		}()
+	}
+}
+
+func (s *Server) handle(conn net.Conn) {
+	defer conn.Close()
+	r := bufio.NewReader(conn)
+	for {
+		opcode, _, requestID, payload, err := readFrame(r)
+		if err != nil {
+			return
+		}
+		body := s.execute(opcode, payload)
+		if _, err := conn.Write(encodeResponse(opcode, requestID, body)); err != nil {
+			return
+		}
+	}
+}
+
+func (s *Server) execute(opcode uint16, payload []byte) []byte {
+	switch opcode {
+	case opPing:
+		return statusOKBody(nil)
+	case opGet:
+		key, ok := parseOneKey(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		v, found := s.data[string(key)]
+		lsn := s.lsns[string(key)]
+		s.mu.Unlock()
+		if !found {
+			return statusBody(statusNotFound, nil)
+		}
+		body := make([]byte, 14+len(v))
+		putU16(body[0:2], statusOK)
+		putU64(body[2:10], lsn)
+		putU32(body[10:14], uint32(len(v)))
+		copy(body[14:], v)
+		return body
+	case opPut:
+		key, value, ok := parsePut(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		s.nextLSN++
+		lsn := s.nextLSN
+		s.data[string(key)] = append([]byte(nil), value...)
+		s.lsns[string(key)] = lsn
+		s.mu.Unlock()
+		body := make([]byte, 10)
+		putU16(body[0:2], statusOK)
+		putU64(body[2:10], lsn)
+		return body
+	case opDelete:
+		key, ok := parseOneKey(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		_, found := s.data[string(key)]
+		delete(s.data, string(key))
+		delete(s.lsns, string(key))
+		s.mu.Unlock()
+		body := make([]byte, 3)
+		putU16(body[0:2], statusOK)
+		if found {
+			body[2] = 1
+		}
+		return body
+	case opExists:
+		key, ok := parseOneKey(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		_, found := s.data[string(key)]
+		s.mu.Unlock()
+		body := make([]byte, 3)
+		putU16(body[0:2], statusOK)
+		if found {
+			body[2] = 1
+		}
+		return body
+	case opMultiGet:
+		keys, ok := parseMultiGet(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		body := make([]byte, 6)
+		putU16(body[0:2], statusOK)
+		putU32(body[2:6], uint32(len(keys)))
+		s.mu.Lock()
+		for _, key := range keys {
+			v, found := s.data[string(key)]
+			if !found {
+				body = append(body, make([]byte, 16)...)
+				continue
+			}
+			entry := make([]byte, 16+len(v))
+			entry[0] = 1
+			putU32(entry[4:8], uint32(len(v)))
+			putU64(entry[8:16], s.lsns[string(key)])
+			copy(entry[16:], v)
+			body = append(body, entry...)
+		}
+		s.mu.Unlock()
+		return body
+	case opBatchWrite:
+		ops, ok := parseBatchOps(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		s.nextLSN++
+		lsn := s.nextLSN
+		applyOps(s.data, s.lsns, ops, lsn)
+		s.mu.Unlock()
+		body := make([]byte, 10)
+		putU16(body[0:2], statusOK)
+		putU64(body[2:10], lsn)
+		return body
+	case opCompareBatch:
+		checks, ops, ok := parseCompareBatch(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		committed := true
+		for _, c := range checks {
+			if c.LSN == 0 {
+				if _, found := s.data[string(c.Key)]; found {
+					committed = false
+					break
+				}
+			} else if s.lsns[string(c.Key)] != c.LSN {
+				committed = false
+				break
+			}
+		}
+		var lsn uint64
+		if committed {
+			s.nextLSN++
+			lsn = s.nextLSN
+			applyOps(s.data, s.lsns, ops, lsn)
+		}
+		s.mu.Unlock()
+		body := make([]byte, 18)
+		putU16(body[0:2], statusOK)
+		if committed {
+			body[2] = 1
+		}
+		putU64(body[10:18], lsn)
+		return body
+	case opScanOpen:
+		includeValues, limit, prefix, ok := parseScanOpen(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		keys := make([]string, 0)
+		for k := range s.data {
+			if strings.HasPrefix(k, string(prefix)) {
+				keys = append(keys, k)
+			}
+		}
+		sort.Strings(keys)
+		s.nextScan++
+		id := s.nextScan
+		s.scans[id] = &scan{keys: keys, limit: limit, keysOnly: !includeValues}
+		s.mu.Unlock()
+		body := make([]byte, 10)
+		putU16(body[0:2], statusOK)
+		putU64(body[2:10], id)
+		return body
+	case opScanNext:
+		id, ok := parseScanID(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		sc := s.scans[id]
+		if sc == nil {
+			s.mu.Unlock()
+			return errorBody("ScanNotFound")
+		}
+		remaining := len(sc.keys) - sc.offset
+		count := int(sc.limit)
+		if count > remaining {
+			count = remaining
+		}
+		end := sc.offset + count
+		body := make([]byte, 10)
+		putU16(body[0:2], statusOK)
+		if end >= len(sc.keys) {
+			body[2] = 1
+		}
+		putU32(body[6:10], uint32(count))
+		for _, key := range sc.keys[sc.offset:end] {
+			value := s.data[key]
+			if sc.keysOnly {
+				value = nil
+			}
+			entry := make([]byte, 16+len(key)+len(value))
+			putU32(entry[0:4], uint32(len(key)))
+			putU32(entry[4:8], uint32(len(value)))
+			putU64(entry[8:16], s.lsns[key])
+			copy(entry[16:], key)
+			copy(entry[16+len(key):], value)
+			body = append(body, entry...)
+		}
+		sc.offset = end
+		s.mu.Unlock()
+		return body
+	case opScanClose:
+		id, ok := parseScanID(payload)
+		if !ok {
+			return errorBody("InvalidPayload")
+		}
+		s.mu.Lock()
+		delete(s.scans, id)
+		s.mu.Unlock()
+		body := make([]byte, 3)
+		putU16(body[0:2], statusOK)
+		body[2] = 1
+		return body
+	default:
+		return errorBody("UnknownOpcode")
+	}
+}
+
+func applyOps(data map[string][]byte, lsns map[string]uint64, ops []storage.BatchOp, lsn uint64) {
+	for _, op := range ops {
+		if op.Op == batchPut {
+			data[string(op.Key)] = append([]byte(nil), op.Value...)
+			lsns[string(op.Key)] = lsn
+		} else {
+			delete(data, string(op.Key))
+			delete(lsns, string(op.Key))
+		}
+	}
+}
+
+// ── wire helpers ────────────────────────────────────────────────────────────
+
+func crc32c(p []byte) uint32 { return crc32.Checksum(p, crcTable) }
+
+func putU16(b []byte, v uint16) { b[0] = byte(v); b[1] = byte(v >> 8) }
+func putU32(b []byte, v uint32) {
+	b[0] = byte(v)
+	b[1] = byte(v >> 8)
+	b[2] = byte(v >> 16)
+	b[3] = byte(v >> 24)
+}
+func putU64(b []byte, v uint64) {
+	b[0] = byte(v)
+	b[1] = byte(v >> 8)
+	b[2] = byte(v >> 16)
+	b[3] = byte(v >> 24)
+	b[4] = byte(v >> 32)
+	b[5] = byte(v >> 40)
+	b[6] = byte(v >> 48)
+	b[7] = byte(v >> 56)
+}
+func getU16(b []byte) uint16 { return uint16(b[0]) | uint16(b[1])<<8 }
+func getU32(b []byte) uint32 {
+	return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
+}
+func getU64(b []byte) uint64 {
+	return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
+		uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56
+}
+
+func encodeFrame(opcode, flags uint16, requestID uint64, payload []byte) []byte {
+	frame := make([]byte, headerSize+len(payload))
+	copy(frame[0:4], headerMagic)
+	putU16(frame[4:6], headerVersion)
+	putU16(frame[8:10], opcode)
+	putU16(frame[10:12], flags)
+	putU64(frame[12:20], requestID)
+	putU32(frame[20:24], uint32(len(payload)))
+	putU32(frame[24:28], crc32c(payload))
+	putU32(frame[28:32], crc32c(frame[0:32]))
+	copy(frame[32:], payload)
+	return frame
+}
+
+func encodeResponse(opcode uint16, requestID uint64, body []byte) []byte {
+	return encodeFrame(opcode|0x8000, 1, requestID, body)
+}
+
+func readFrame(r *bufio.Reader) (uint16, uint16, uint64, []byte, error) {
+	var header [headerSize]byte
+	if _, err := io.ReadFull(r, header[:]); err != nil {
+		return 0, 0, 0, nil, err
+	}
+	payloadLen := getU32(header[20:24])
+	payload := make([]byte, payloadLen)
+	if _, err := io.ReadFull(r, payload); err != nil {
+		return 0, 0, 0, nil, err
+	}
+	return getU16(header[8:10]), getU16(header[10:12]), getU64(header[12:20]), payload, nil
+}
+
+func statusOKBody(p []byte) []byte {
+	body := make([]byte, 2+len(p))
+	putU16(body[0:2], statusOK)
+	copy(body[2:], p)
+	return body
+}
+
+func statusBody(status uint16, p []byte) []byte {
+	body := make([]byte, 2+len(p))
+	putU16(body[0:2], status)
+	copy(body[2:], p)
+	return body
+}
+
+func errorBody(msg string) []byte {
+	body := make([]byte, 2+len(msg))
+	putU16(body[0:2], statusError)
+	copy(body[2:], msg)
+	return body
+}
+
+func parseOneKey(p []byte) ([]byte, bool) {
+	if len(p) < 4 {
+		return nil, false
+	}
+	l := getU32(p[0:4])
+	if 4+int(l) != len(p) {
+		return nil, false
+	}
+	return p[4:], true
+}
+
+func parsePut(p []byte) ([]byte, []byte, bool) {
+	if len(p) < 8 {
+		return nil, nil, false
+	}
+	kl := getU32(p[0:4])
+	vl := getU32(p[4:8])
+	if 8+int(kl)+int(vl) != len(p) {
+		return nil, nil, false
+	}
+	return p[8 : 8+kl], p[8+kl:], true
+}
+
+func parseMultiGet(p []byte) ([][]byte, bool) {
+	if len(p) < 4 {
+		return nil, false
+	}
+	n := getU32(p[0:4])
+	keys := make([][]byte, 0, n)
+	pos := 4
+	for i := uint32(0); i < n; i++ {
+		if len(p)-pos < 4 {
+			return nil, false
+		}
+		l := getU32(p[pos : pos+4])
+		pos += 4
+		if len(p)-pos < int(l) {
+			return nil, false
+		}
+		keys = append(keys, p[pos:pos+int(l)])
+		pos += int(l)
+	}
+	return keys, pos == len(p)
+}
+
+func parseBatchOps(p []byte) ([]storage.BatchOp, bool) {
+	if len(p) < 8 {
+		return nil, false
+	}
+	n := getU32(p[0:4])
+	ml := getU32(p[4:8])
+	pos := 8 + int(ml)
+	ops := make([]storage.BatchOp, 0, n)
+	for i := uint32(0); i < n; i++ {
+		if len(p)-pos < 12 {
+			return nil, false
+		}
+		op := p[pos]
+		kl := getU32(p[pos+4 : pos+8])
+		vl := getU32(p[pos+8 : pos+12])
+		pos += 12
+		if op != batchPut && op != batchDelete {
+			return nil, false
+		}
+		if len(p)-pos < int(kl)+int(vl) {
+			return nil, false
+		}
+		key := p[pos : pos+int(kl)]
+		pos += int(kl)
+		value := p[pos : pos+int(vl)]
+		pos += int(vl)
+		ops = append(ops, storage.BatchOp{Op: op, Key: key, Value: value})
+	}
+	return ops, pos == len(p)
+}
+
+func parseCompareBatch(p []byte) ([]storage.CompareCheck, []storage.BatchOp, bool) {
+	if len(p) < 16 {
+		return nil, nil, false
+	}
+	nc := getU32(p[0:4])
+	no := getU32(p[4:8])
+	ml := getU32(p[8:12])
+	pos := 16
+	checks := make([]storage.CompareCheck, 0, nc)
+	for i := uint32(0); i < nc; i++ {
+		if len(p)-pos < 16 {
+			return nil, nil, false
+		}
+		kl := getU32(p[pos : pos+4])
+		lsn := getU64(p[pos+8 : pos+16])
+		pos += 16
+		if len(p)-pos < int(kl) {
+			return nil, nil, false
+		}
+		checks = append(checks, storage.CompareCheck{Key: p[pos : pos+int(kl)], LSN: lsn})
+		pos += int(kl)
+	}
+	pos += int(ml)
+	ops := make([]storage.BatchOp, 0, no)
+	for i := uint32(0); i < no; i++ {
+		if len(p)-pos < 12 {
+			return nil, nil, false
+		}
+		op := p[pos]
+		kl := getU32(p[pos+4 : pos+8])
+		vl := getU32(p[pos+8 : pos+12])
+		pos += 12
+		if op != batchPut && op != batchDelete {
+			return nil, nil, false
+		}
+		if len(p)-pos < int(kl)+int(vl) {
+			return nil, nil, false
+		}
+		key := p[pos : pos+int(kl)]
+		pos += int(kl)
+		value := p[pos : pos+int(vl)]
+		pos += int(vl)
+		ops = append(ops, storage.BatchOp{Op: op, Key: key, Value: value})
+	}
+	return checks, ops, pos == len(p)
+}
+
+func parseScanOpen(p []byte) (bool, uint32, []byte, bool) {
+	if len(p) < 12 {
+		return false, 0, nil, false
+	}
+	include := p[0] != 0
+	limit := getU32(p[4:8])
+	pl := getU32(p[8:12])
+	if 12+int(pl) != len(p) {
+		return false, 0, nil, false
+	}
+	return include, limit, p[12:], true
+}
+
+func parseScanID(p []byte) (uint64, bool) {
+	if len(p) < 8 {
+		return 0, false
+	}
+	return getU64(p[0:8]), true
+}