Skip to content

db: configurable commit durability and fullfsync, pg test-DB reuse - #726

Merged
Roasbeef merged 2 commits into
mainfrom
db-storage-knobs
Jun 16, 2026
Merged

db: configurable commit durability and fullfsync, pg test-DB reuse#726
Roasbeef merged 2 commits into
mainfrom
db-storage-knobs

Conversation

@Roasbeef

@Roasbeef Roasbeef commented Jun 10, 2026

Copy link
Copy Markdown
Member

In this PR, we expose the SQLite storage durability levers that the OOR
benchmarking campaign showed were the first two ceilings on payment
throughput, and we make the postgres test harness reuse its database across
daemon restarts so restart-style systests work on both backends.

The headline knob is commit durability: synchronous is now configurable,
defaulting to NORMAL on the client (was FULL). Under WAL, NORMAL omits the
per-commit WAL fsync (the WAL is synced only before a checkpoint), so a power
loss can roll back the last few commits but can never corrupt the database; an
OS or app crash loses nothing. That contract composes cleanly with the durable
mailbox model, where every cross-actor message is redelivered until acked, so a
dropped tail commit replays rather than strands. On the high-contention stress
shape this single default was worth 1.3-2.3x throughput with p50 falling from
~6s to ~700ms: the per-commit fsync was the latency tail.

The second knob is macOS specific: sqlite on darwin issues F_FULLFSYNC (a full
disk-barrier flush), a hidden second fsync tax that profiling showed cost ~35%
throughput on the bench host. Under NORMAL it governs the WAL checkpoint sync
rather than a per-commit fsync, but checkpoints recur continuously under
sustained write load, so the fullfsync pragma is now configurable. The
default is unchanged.

Both knobs are exposed on the daemon under the db.sqlite.* namespace
(db.sqlite.synchronous, db.sqlite.nofullfsync), matching the dotted config
convention used elsewhere; the db.postgres.* namespace is reserved.
Postgres-side durability tuning (synchronous_commit) is deliberately deferred
to a separate change, and the operator-side default is a separate decision that
stays FULL over there (darepo#553).

The test-infra commit keys the postgres test database off the dbPath so a
restarted daemon lands on the same database rather than a fresh one, which the
restart-recovery systests depend on.

This PR builds on #693 and is part of the OOR optimization train.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces configurable database durability levels for both SQLite and Postgres backends, defaulting to a "normal" durability level. It also adds a macOS-specific knob to disable the SQLite fullfsync pragma for higher write throughput, and implements a Postgres test fixture caching mechanism to correctly simulate daemon restarts in tests. Feedback highlights a performance bottleneck in tests where a global mutex is held during Docker container startup, a shared configuration mutation bug, and case-sensitivity issues when parsing synchronous level configurations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread db/test_postgres.go
Comment thread db/sqlite.go Outdated
Comment on lines +281 to +289
func resolveSqliteSynchronous(value string) (string, error) {
if value == "" {
return defaultSqliteSynchronous, nil
}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Case-Sensitivity in Configuration Parsing

The resolveSqliteSynchronous function performs a case-sensitive check against the allowed values (full, normal, off). If an operator configures Synchronous using uppercase or mixed-case values (e.g., Normal or FULL), the daemon will fail to start. Normalizing the input to lowercase (e.g., using strings.ToLower) before validation would make the configuration parsing more robust and user-friendly.

Suggested change
func resolveSqliteSynchronous(value string) (string, error) {
if value == "" {
return defaultSqliteSynchronous, nil
}
switch value {
case SqliteSynchronousFull, SqliteSynchronousNormal,
SqliteSynchronousOff:
return value, nil
func resolveSqliteSynchronous(value string) (string, error) {
level := strings.ToLower(value)
if level == "" {
return defaultSqliteSynchronous, nil
}
switch level {
case SqliteSynchronousFull, SqliteSynchronousNormal,
SqliteSynchronousOff:
return level, nil

Comment thread db/postgres.go Outdated
Comment on lines +91 to +105
func resolvePostgresSynchronousCommit(value string) (string, error) {
level := value
if level == "" {
level = defaultPostgresSynchronous
}

switch level {
case SqliteSynchronousFull:
return "on", nil

case SqliteSynchronousNormal:
return "local", nil

case SqliteSynchronousOff:
return "off", nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Case-Sensitivity in Configuration Parsing

The resolvePostgresSynchronousCommit function performs a case-sensitive check against the allowed values (full, normal, off). If an operator configures Synchronous using uppercase or mixed-case values (e.g., Normal or FULL), the daemon will fail to start. Normalizing the input to lowercase (e.g., using strings.ToLower) before validation would make the configuration parsing more robust and user-friendly.

Suggested change
func resolvePostgresSynchronousCommit(value string) (string, error) {
level := value
if level == "" {
level = defaultPostgresSynchronous
}
switch level {
case SqliteSynchronousFull:
return "on", nil
case SqliteSynchronousNormal:
return "local", nil
case SqliteSynchronousOff:
return "off", nil
func resolvePostgresSynchronousCommit(value string) (string, error) {
level := strings.ToLower(value)
if level == "" {
level = defaultPostgresSynchronous
}
switch level {
case SqliteSynchronousFull:
return "on", nil
case SqliteSynchronousNormal:
return "local", nil
case SqliteSynchronousOff:
return "off", nil

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6430a8a649

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread darepod/server.go Outdated
Comment on lines +2981 to +2982
sqliteCfg := db.DefaultSqliteConfig(networkDir)
sqliteCfg.NoFullfsync = s.cfg.DBNoFullfsync

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire the synchronous knob through darepod config

For the actual darepod client path, DefaultSqliteConfig now defaults Synchronous to normal, but initDatabase only copies the new fullfsync option from daemon config and never exposes or forwards a synchronous setting. In standalone or embedded darepod deployments that need the previous FULL power-loss durability, there is no CLI/config/env way to restore it even though db.SqliteConfig now supports "full"; the only reachable behavior is the weaker default.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6430a8a649

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread darepod/server.go Outdated
Comment on lines +2981 to +2982
sqliteCfg := db.DefaultSqliteConfig(networkDir)
sqliteCfg.NoFullfsync = s.cfg.DBNoFullfsync

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire the synchronous knob through darepod config

For the actual darepod client path, DefaultSqliteConfig now defaults Synchronous to normal, but initDatabase only copies the new fullfsync option from daemon config and never exposes or forwards a synchronous setting. In standalone or embedded darepod deployments that need the previous FULL power-loss durability, there is no CLI/config/env way to restore it even though db.SqliteConfig now supports "full"; the only reachable behavior is the weaker default.

Useful? React with 👍 / 👎.

@Roasbeef
Roasbeef force-pushed the claude/hardcore-murdock-d976a2 branch 3 times, most recently from 819feee to efaceb1 Compare June 15, 2026 04:06
@Roasbeef
Roasbeef force-pushed the db-storage-knobs branch 3 times, most recently from 27a1bf4 to 1047332 Compare June 15, 2026 04:52
@levmi levmi added the P1 Priority 1 — high label Jun 15, 2026
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jun 16, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 1m 57s —— View job


PR Review: db: configurable commit durability and fullfsync, pg test-DB reuse

  • Read the diff across all 8 changed files
  • Review SQLite durability knobs (db/sqlite.go)
  • Review darepod config/server wiring
  • Review postgres test-DB reuse
  • Assess prior bot feedback
  • Post review

Solid, well-documented change. The comments around the durability semantics are excellent — they capture exactly why normal is safe given the at-least-once idempotent persistence stack, which is the part a future reader will need. Below are my findings, ordered by severity.

Correctness

1. db/test_postgres.go — global mutex held across Docker container startup (test-only perf, but real)

NewTestDBHandleFromPath takes testPgFixtureMtx with defer and holds it for the entire body, including NewTestPgFixture(...) which spins up a Docker/postgres container (seconds). Since the cache is keyed by dbPath and dbPaths are unique per client per test, two parallel tests never contend on the same key — yet they fully serialize on fixture creation. For make systest db=postgres with parallel tests this serializes every container bringup.

This is the bottleneck Gemini flagged. The clean fix is to only hold the lock around the map read/write and release it during the expensive bringup:

testPgFixtureMtx.Lock()
sqlFixture, ok := testPgFixturesByPath[dbPath]
testPgFixtureMtx.Unlock()

then re-lock only to publish into the map. Because keys are unique per test, there's no TOCTOU concern in practice. Not blocking, but worth doing since systest wall-clock is the whole point of this campaign. Fix this →

2. Gemini's "shared config mutation" concern is a non-issue — please don't "fix" it

Gemini flagged storeCfg := sqlFixture.GetConfig(); storeCfg.SkipMigrations = true as mutating shared state. It isn't: TestPgFixture.GetConfig() (db/postgres_fixture.go:114) constructs and returns a fresh &PostgresConfig{...} on every call, so the mutation is local. This matches the existing pattern in NewTestPostgresDBWithVersion (db/postgres.go:263). No change needed.

Wiring

3. Codex's "synchronous not wired through darepod" — already addressed ✅

The reviewed commit (6430a8a) predated the wiring. The current branch fully forwards it: darepod/server.go:2982 sets sqliteCfg.Synchronous from s.cfg.DB.Sqlite.Synchronous (only when non-empty, preserving the DefaultSqliteConfig normal), and cmd/darepod/main.go:220 exposes --db.sqlite.synchronous. An operator can restore FULL durability. Good.

One small note: DBConfig is a value type and DefaultConfig() (darepod/config.go:781) doesn't initialize it, so cfg.DB.Sqlite.Synchronous defaults to "". That's intentional and correct here — "" flows through to NewSqliteStoreresolveSqliteSynchronous("")normal, and the help text documents "empty defaults to normal". Consistent.

Style / minor

4. resolveSqliteSynchronous is case-sensitive (judgment call)

Gemini suggested strings.ToLower. There's a real trade-off: strict matching surfaces typos like Noraml at startup (a feature, per your own comment), but it also rejects FULL/Normal which operators may reasonably type given SQLite docs show the pragma in uppercase. I'd lean toward normalizing case (strings.ToLower) while keeping the unknown-value rejection — you get both typo-safety and case tolerance. Non-blocking; your call.

5. TestSqliteFullfsyncKnob runs on Linux CI

PRAGMA fullfsync is accepted (and its value read back) on all platforms even though the flush behavior only matters on Darwin, so reading back 1/0 correctly verifies the DSN→pragma plumbing. Just flagging that the test asserts the pragma is set, not the macOS flush behavior — which is the right thing to test cross-platform. No action needed.

Things I checked and liked

  • db/actordelivery/migrations_test.go updated to synchronous=normal to keep the concurrent test harness aligned with the new production default — easy to forget, good catch.
  • strconv.FormatBool(!cfg.NoFullfsync) correctly inverts the negative-sense flag so the default (NoFullfsync=false) keeps fullfsync=true.
  • The fixture-reuse teardown ordering (LIFO: store.DB.Close before TearDown) and the idempotent double-close are reasoned through carefully in the doc comment.
  • Validation happens in NewSqliteStore before DSN construction, so a bad value fails fast at startup rather than silently weakening durability.

Overall: no blocking correctness bugs. #1 is the only one I'd recommend acting on before merge (test throughput); #4 is a friendliness nicety.
db-storage-knobs

@Roasbeef
Roasbeef changed the base branch from claude/hardcore-murdock-d976a2 to main June 16, 2026 22:56
The systest restart harness (CrashRestartClient) simulates a daemon
restart by reopening the SAME dbPath and expects the previously
persisted state to survive. On sqlite this works for free because
NewTestDBHandleFromPath reattaches to the on-disk file at dbPath and
the restart opens a fresh handle over it.

The postgres variant ignored dbPath and called NewTestPostgresDB on
every invocation, spinning up a brand-new empty database each time.
A restart therefore silently dropped all persisted state (OOR
sessions, wallet, boarding), so four restart-recovery systests passed
on sqlite but failed on postgres. CI never caught it because the
systest job only runs on sqlite.

Memoize the postgres fixture (the docker container and its database)
per dbPath behind a mutex-guarded map. The first handle for a path
creates the database, runs migrations, and registers teardown. Later
handles for the same path reuse that database but open a fresh
connection pool with migrations skipped, since the prior store's pool
is closed on shutdown and the schema already exists. Each reuse also
registers a cleanup that closes its pool (sql.DB.Close is idempotent),
and the same-testing.TB / unique-dbPath invariant is documented so the
memoization is only ever reused within the test that first opened it.
dbPaths are unique per client per test (temp dirs), so reuse only
happens within a single test (the original handle plus its restart).
The per-commit fsync forced by synchronous=full was the throughput
ceiling for the persistence stack: under multi-actor write contention
it ran roughly 3.9x slower than NORMAL in benchmarks, since every
commit blocked on an extra disk sync.

NORMAL is safe here because the OOR/outbox/serverconn stack is
at-least-once, idempotent, and deterministic. Under WAL mode NORMAL
omits the per-commit WAL fsync (the WAL is synced only before a
checkpoint), so a process crash loses zero durably-committed work, a
power loss is recoverable by deterministic replay of the dropped tail,
and NORMAL is not OFF, so the database is never corrupted.

The second knob is macOS-specific. The fullfsync pragma only matters on
darwin, where a plain fsync does not guarantee data reached stable
storage. Under NORMAL it governs the WAL checkpoint sync rather than any
per-commit work, but checkpoints recur continuously under sustained
write load and each F_FULLFSYNC waits on a full hardware cache flush:
the stress benchmark measures it at roughly a third of attainable
throughput (10.4 -> 14.2 payments/s with it disabled).

In this commit, we expose both as configurable knobs on the SQLite
backend. The synchronous level (full/normal/off) defaults to NORMAL and
rejects unknown values at startup; fullfsync stays enabled by default.
On the daemon they live under the db.sqlite.* namespace
(db.sqlite.synchronous, db.sqlite.nofullfsync), matching the dotted
config convention used elsewhere; the db.postgres.* namespace is
reserved. Postgres-side durability tuning is deferred to a separate
change.
@Roasbeef
Roasbeef merged commit 4fae9b7 into main Jun 16, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1 Priority 1 — high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants