Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/darepod/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,23 @@ func newRootCmd() *cobra.Command {
"otherwise",
)

// SQLite database durability knobs (db.sqlite.* namespace). The
// db.postgres.* namespace is reserved for a future Postgres-tuning
// change; the daemon always opens SQLite.
f.String(
"db.sqlite.synchronous", cfg.DB.Sqlite.Synchronous, "the "+
"SQLite synchronous (commit durability) level: one "+
"of full, normal, off; empty defaults to normal, "+
"which under WAL mode omits the per-commit fsync "+
"of full for higher write throughput",
)
f.Bool(
"db.sqlite.nofullfsync", cfg.DB.Sqlite.NoFullfsync, "disable"+
" the SQLite fullfsync pragma (macOS only); trades "+
"power-loss flush guarantees for higher sustained "+
"write throughput",
)

// OOR safety limits. These are advanced knobs; most operators
// should keep the defaults unless a limit-exceeded error says
// otherwise after a protocol upgrade or operator/indexer change.
Expand Down
45 changes: 45 additions & 0 deletions darepod/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,58 @@ type Config struct {
// sdk/walletdk embedded path).
EagerRoundJoin bool `mapstructure:"eagerroundjoin"`

// DB groups the per-backend database tuning knobs under the db.sqlite.*
// and db.postgres.* namespaces. A value type so a zero-value Config can
// never carry a nil sub-config into the start path.
DB DBConfig `mapstructure:"db"`

// Pprof configures the optional net/http/pprof debug server. It is
// disabled by default and must be explicitly opted into via a
// non-empty listen address. A value type so a zero-value Config can
// never carry a nil pprof config into the start path.
Pprof PprofConfig `mapstructure:"pprof"`
}

// DBConfig groups the per-backend database tuning knobs. Only the SQLite
// knobs are wired today; the daemon always opens SQLite, so the Postgres
// namespace is reserved for a future Postgres-tuning change.
type DBConfig struct {
// Sqlite holds the SQLite-backend durability knobs, exposed on the
// daemon under the db.sqlite.* namespace.
Sqlite DBSqliteConfig `mapstructure:"sqlite"`

// Postgres is reserved for future Postgres-backend tuning knobs. It is
// intentionally empty today: the daemon always opens SQLite, and
// Postgres durability tuning is deferred to a separate change.
Postgres DBPostgresConfig `mapstructure:"postgres"`
}

// DBSqliteConfig holds the SQLite-backend durability knobs exposed on the
// daemon under the db.sqlite.* namespace.
type DBSqliteConfig struct {
// Synchronous selects the SQLite synchronous (commit durability)
// level. One of "full", "normal", or "off"; an empty value resolves to
// the safe default ("normal"). Under WAL mode "normal" omits the
// per-commit WAL fsync of "full" for substantially higher write
// throughput, replaying any tail dropped on power loss via the
// idempotent persistence stack. See db.SqliteConfig.Synchronous.
Synchronous string `mapstructure:"synchronous"`

// NoFullfsync disables the SQLite fullfsync pragma. The pragma only
// matters on macOS, where it makes flushes wait on a full hardware
// cache flush; with the default synchronous=normal level it governs
// the WAL checkpoint sync, which recurs continuously under sustained
// write load. Write-heavy macOS deployments that accept the weaker
// flush guarantee can disable it for substantially higher throughput.
// No effect on other platforms. See db.SqliteConfig.NoFullfsync.
NoFullfsync bool `mapstructure:"nofullfsync"`
}

// DBPostgresConfig is reserved for future Postgres-backend tuning knobs. It
// is intentionally empty: the daemon always opens SQLite, and Postgres
// durability tuning is deferred to a separate change.
type DBPostgresConfig struct{}

// RPCServiceRegistrar registers one optional daemon gRPC subserver on the
// daemon's existing listener.
//
Expand Down
7 changes: 7 additions & 0 deletions darepod/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2943,6 +2943,13 @@ func (s *Server) initDatabase(ctx context.Context) error {
}

sqliteCfg := db.DefaultSqliteConfig(networkDir)
sqliteCfg.NoFullfsync = s.cfg.DB.Sqlite.NoFullfsync

// An empty level keeps the DefaultSqliteConfig default ("normal"); a
// non-empty operator override is validated in NewSqliteStore.
if s.cfg.DB.Sqlite.Synchronous != "" {
sqliteCfg.Synchronous = s.cfg.DB.Sqlite.Synchronous
}

var err error
s.db, err = db.NewSqliteStore(
Expand Down
5 changes: 3 additions & 2 deletions db/actordelivery/migrations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ func newSQLiteDB(t *testing.T) *sql.DB {

// newConcurrentSQLiteDB opens a sqlite test database configured exactly like
// the production store (see db/sqlite.go): WAL journaling, a 30s busy_timeout,
// synchronous=full, and _txlock=immediate, with the same multi-connection pool.
// synchronous=normal, and _txlock=immediate, with the same multi-connection
// pool.
// Tests that drive concurrent writers (e.g. a multi-worker durable actor) must
// use this rather than the bare newSQLiteDB: _txlock=immediate plus
// busy_timeout is what lets concurrent write transactions serialize by waiting
Expand All @@ -41,7 +42,7 @@ func newConcurrentSQLiteDB(t testing.TB) *sql.DB {
"foreign_keys=on",
"journal_mode=WAL",
"busy_timeout=30000",
"synchronous=full",
"synchronous=normal",
"fullfsync=true",
}
opts := make(url.Values)
Expand Down
115 changes: 106 additions & 9 deletions db/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"log/slog"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -40,6 +42,36 @@ const (
// defaultConnMaxLifetime is the maximum amount of time a connection can
// be reused for before it is closed.
defaultConnMaxLifetime = 10 * time.Minute

// defaultSqliteSynchronous is the default value for the SQLite
// synchronous pragma. We default to "normal" rather than "full"
// because, under WAL mode, "normal" omits the per-commit WAL fsync
// entirely (the WAL is synced only before a checkpoint), which is
// exactly what makes it faster than "full". A committed transaction is
// still durable across an application/process crash, but a power loss
// or OS crash can roll back the recently committed tail still sitting
// in the un-synced WAL. The at-least-once, idempotent, deterministic
// OOR/outbox/serverconn stack recovers from a process crash with zero
// loss and replays that dropped tail after power loss, so the
// per-commit fsync of "full" is an unneeded throughput ceiling.
// "normal" never corrupts the database ("off" is not corruption-safe).
defaultSqliteSynchronous = SqliteSynchronousNormal

// SqliteSynchronousFull is the strictest SQLite synchronous level. It
// fsyncs on every commit, trading throughput for the strongest
// per-commit durability guarantee.
SqliteSynchronousFull = "full"

// SqliteSynchronousNormal relaxes the synchronous pragma to "normal".
// Under WAL mode this omits the per-commit WAL fsync (syncing the WAL
// only before a checkpoint), which is safe given our recoverable,
// idempotent persistence stack.
SqliteSynchronousNormal = "normal"

// SqliteSynchronousOff disables synchronous flushing entirely. This is
// the most aggressive (and least durable) level; a power loss may lose
// recently committed transactions, but it never corrupts the database.
SqliteSynchronousOff = "off"
)

// SqliteConfig holds all the config arguments needed to interact with our
Expand All @@ -59,6 +91,23 @@ type SqliteConfig struct {
// found.
DatabaseFileName string `long:"dbfile" description:"The full path to the database."`

// Synchronous controls the SQLite synchronous pragma, which governs
// commit durability. Valid values are "full", "normal", and "off".
// When empty it defaults to "normal", which under WAL mode omits the
// per-commit WAL fsync of "full" (the WAL is synced only before a
// checkpoint).
Synchronous string `long:"synchronous" description:"The SQLite synchronous (commit durability) level. One of: full, normal, off."`

// NoFullfsync disables the SQLite fullfsync pragma. The pragma only
// matters on macOS, where a regular fsync does not guarantee the data
// reached stable storage; with synchronous=normal it governs the WAL
// checkpoint sync. Checkpoints fire continuously under a sustained
// write load and F_FULLFSYNC waits on a full hardware cache flush, so
// write-heavy deployments that accept the weaker flush guarantee can
// disable it for substantially better throughput. The default keeps
// fullfsync enabled.
NoFullfsync bool `long:"nofullfsync" description:"Disable the macOS fullfsync pragma; trades power-loss flush guarantees on macOS for higher sustained write throughput. No effect on other platforms."`

// Log is an optional logger for the SQLite store. When None, the store
// falls back to the explicit constructor logger.
Log fn.Option[btclog.Logger]
Expand All @@ -83,6 +132,13 @@ func NewSqliteStore(cfg *SqliteConfig,
// back to the explicitly provided logger parameter.
storeLog := cfg.Log.UnwrapOr(explicitLog)

// Resolve and validate the configured synchronous level before we build
// the DSN, normalizing an empty value to the safe default.
synchronous, err := resolveSqliteSynchronous(cfg.Synchronous)
if err != nil {
return nil, err
}

// The set of pragma options are accepted using query options. For now
// we only want to ensure that foreign key constraints are properly
// enforced.
Expand Down Expand Up @@ -115,19 +171,29 @@ func NewSqliteStore(cfg *SqliteConfig,
value: "30000",
},
{
// With the WAL mode, this ensures that we also do an
// extra WAL sync after each transaction. The normal
// sync mode skips this and gives better performance,
// but risks durability.
// The synchronous pragma governs commit durability.
// Under WAL mode, "full" fsyncs the WAL on every
// commit; "normal" (our default) omits that per-commit
// fsync and syncs the WAL only before a checkpoint, for
// substantially better throughput at the cost of the
// recently committed tail on power loss. The value is
// configurable so operators can trade durability for
// performance. See resolveSqliteSynchronous for the
// accepted values.
name: "synchronous",
value: "full",
value: synchronous,
},
{
// This is used to ensure proper durability for users
// running on Mac OS. It uses the correct fsync system
// call to ensure items are fully flushed to disk.
// fullfsync uses the correct fsync system call on macOS
// so that flushed data is genuinely durable. Under
// "normal" it governs the WAL checkpoint sync rather
// than a per-commit fsync, but checkpoints recur
// continuously under sustained write load and each
// F_FULLFSYNC waits on a full hardware cache flush, so
// the config exposes an opt-out for write-heavy
// deployments. Enabled by default.
name: "fullfsync",
value: "true",
value: strconv.FormatBool(!cfg.NoFullfsync),
},
}
sqliteOptions := make(url.Values)
Expand All @@ -148,6 +214,8 @@ func NewSqliteStore(cfg *SqliteConfig,

storeLog.InfoS(ctx, "Opening SQLite database",
slog.String("db_file", cfg.DatabaseFileName),
slog.String("synchronous", synchronous),
slog.Bool("fullfsync", !cfg.NoFullfsync),
slog.Int("max_conns", defaultMaxConns),
slog.Duration("conn_max_lifetime", defaultConnMaxLifetime),
)
Expand Down Expand Up @@ -213,6 +281,35 @@ func NewSqliteStore(cfg *SqliteConfig,
return s, nil
}

// resolveSqliteSynchronous normalizes and validates a configured SQLite
// synchronous level. An empty value resolves to the package default
// (defaultSqliteSynchronous); any other value must be one of "full",
// "normal", or "off". Unknown values are rejected with a descriptive error so
// a typo surfaces at startup rather than silently weakening durability.
func resolveSqliteSynchronous(value string) (string, error) {
if value == "" {
return defaultSqliteSynchronous, nil
}

// Normalize case before validating so an operator can spell the level
// in any case (e.g. "NORMAL"), matching the uppercase form used in the
// durability docs and prose. The resolved value is returned lowercased
// so it feeds the pragma as SQLite expects.
level := strings.ToLower(value)

switch level {
case SqliteSynchronousFull, SqliteSynchronousNormal,
SqliteSynchronousOff:
return level, nil

default:
return "", fmt.Errorf("invalid sqlite synchronous level %q: "+
"must be one of %q, %q, or %q", value,
SqliteSynchronousFull, SqliteSynchronousNormal,
SqliteSynchronousOff)
}
}

// backupSqliteDatabase creates a backup of the given SQLite database. The
// function uses the store's resolved logger for progress messages.
func backupSqliteDatabase(srcDB *sql.DB, dbFullFilePath string,
Expand Down
Loading
Loading