From ba6683c44e46c062963aee37f41a632a71b2f547 Mon Sep 17 00:00:00 2001 From: Anton Tolchanov Date: Mon, 9 Feb 2026 17:52:51 +0000 Subject: [PATCH] sqlite: allow controlling number of reserved bytes This allows reading and changing the number of reserved bytes using sqlite3_file_control with SQLITE_FCNTL_RESERVE_BYTES opcode. Updates tailscale/corp#36342 Signed-off-by: Anton Tolchanov --- cgosqlite/cgosqlite.go | 15 ++++++++++ sqlite.go | 29 +++++++++++++++++++ sqlite_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++ sqliteh/sqliteh.go | 10 +++++++ 4 files changed, 118 insertions(+) diff --git a/cgosqlite/cgosqlite.go b/cgosqlite/cgosqlite.go index c998173..e5f5d9a 100644 --- a/cgosqlite/cgosqlite.go +++ b/cgosqlite/cgosqlite.go @@ -227,6 +227,21 @@ func (db *DB) DisableFunction(name string, numArgs int) error { return errCode(C.ts_sqlite3_disable_function(db.db, cName, C.int(numArgs))) } +func (db *DB) FileControlInt(dbName string, op sqliteh.FileControlOp, arg *int) error { + var cDB *C.char + if dbName != "" { + cDB = C.CString(dbName) + defer C.free(unsafe.Pointer(cDB)) + } + if arg == nil { + return errCode(C.sqlite3_file_control(db.db, cDB, C.int(op), nil)) + } + cArg := C.int(*arg) + res := C.sqlite3_file_control(db.db, cDB, C.int(op), unsafe.Pointer(&cArg)) + *arg = int(cArg) + return errCode(res) +} + func (stmt *Stmt) DBHandle() sqliteh.DB { cdb := C.sqlite3_db_handle(stmt.stmt) if cdb != nil { diff --git a/sqlite.go b/sqlite.go index abec629..7730d9a 100644 --- a/sqlite.go +++ b/sqlite.go @@ -1134,6 +1134,35 @@ func DisableFunction(sqlconn SQLConn, name string, numArgs int) error { }) } +// fileControlInt calls sqlite3_file_control on the underlying connection. +func fileControlInt(sqlconn SQLConn, dbName string, op sqliteh.FileControlOp, arg *int) error { + return sqlconn.Raw(func(driverConn any) error { + c, ok := driverConn.(*conn) + if !ok { + return fmt.Errorf("sqlite.FileControl: sql.Conn is not the sqlite driver: %T", driverConn) + } + return c.db.FileControlInt(dbName, op, arg) + }) +} + +// SetReserveBytes sets the number of reserved bytes at the end of each +// database page, using sqlite3_file_control with SQLITE_FCNTL_RESERVE_BYTES opcode. +func SetReserveBytes(sqlconn SQLConn, dbName string, bytes int) error { + if bytes < 0 || bytes > 255 { + return fmt.Errorf("reserved bytes must be between 0 and 255, got %d", bytes) + } + return fileControlInt(sqlconn, dbName, sqliteh.SQLITE_FCNTL_RESERVE_BYTES, &bytes) +} + +// GetReserveBytes gets the number of reserved bytes at the end of each +// database page. +func GetReserveBytes(sqlconn SQLConn, dbName string) (int, error) { + var bytes int + bytes = -1 // pass a negative value to query the current setting. + err := fileControlInt(sqlconn, dbName, sqliteh.SQLITE_FCNTL_RESERVE_BYTES, &bytes) + return bytes, err +} + // WithPersist makes a ctx instruct the sqlite driver to persist a prepared query. // // This should be used with recurring queries to avoid constant parsing and diff --git a/sqlite_test.go b/sqlite_test.go index de6fab9..aa2b159 100644 --- a/sqlite_test.go +++ b/sqlite_test.go @@ -10,7 +10,9 @@ import ( "database/sql/driver" "expvar" "fmt" + "io" "os" + "path/filepath" "reflect" "runtime" "slices" @@ -1625,3 +1627,65 @@ func TestExpandedSQL(t *testing.T) { t.Errorf("wrong sql: got %q, want %q", got, want) } } + +func TestFileControlReserveBytes(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + db, err := sql.Open("sqlite3", "file:"+dbPath) + if err != nil { + t.Fatal(err) + } + + conn, err := db.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + + if _, err := db.Exec("CREATE TABLE t (id INTEGER)"); err != nil { + t.Fatal(err) + } + if _, err := db.Exec("INSERT INTO t (id) VALUES (1);"); err != nil { + t.Fatal(err) + } + + if got, err := GetReserveBytes(conn, "main"); err != nil { + t.Fatal(err) + } else if got != 0 { + t.Fatalf("initial reserved bytes=%d, want 0", got) + } + + reserve := 16 + if err := SetReserveBytes(conn, "", reserve); err != nil { + t.Fatal(err) + } + + if err := ExecScript(conn, "VACUUM;"); err != nil { + t.Fatal(err) + } + + if got, err := GetReserveBytes(conn, "main"); err != nil { + t.Fatal(err) + } else if got != reserve { + t.Fatalf("reserved bytes=%d, want %d", got, reserve) + } + + if err := conn.Close(); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + header := make([]byte, 100) + file, err := os.Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if _, err := io.ReadFull(file, header); err != nil { + t.Fatal(err) + } + if got := int(header[20]); got != reserve { + t.Fatalf("reserved bytes header=%d, want %d", got, reserve) + } +} diff --git a/sqliteh/sqliteh.go b/sqliteh/sqliteh.go index 69b6a79..c2527be 100644 --- a/sqliteh/sqliteh.go +++ b/sqliteh/sqliteh.go @@ -65,6 +65,9 @@ type DB interface { // function's signature. // DisableFunction(name string, numArgs int) error + // FileControlInt is sqlite3_file_control for opcodes that take an + // integer argument. + FileControlInt(dbName string, op FileControlOp, arg *int) error } // Stmt is an sqlite3_stmt* database connection object. @@ -351,6 +354,13 @@ func (o OpenFlags) String() string { return string(flags) } +// FileControlOp is an opcode for sqlite3_file_control. +type FileControlOp int + +const ( + SQLITE_FCNTL_RESERVE_BYTES FileControlOp = 38 +) + // Checkpoint is a WAL checkpoint mode. // It is used by sqlite3_wal_checkpoint_v2. //