Checkpoint without waiting on readers and log a busy checkpoint (closes #102)
check / check (push) Canceled after 0s

The 5-second checkpoint ran in TRUNCATE mode, which waits up to the
5-second busy timeout for every open read to finish, and it did so
holding the database lock every batch write needs. It now runs in
PASSIVE mode, which never waits; the writing connection's
journal_size_limit (64 MiB) shrinks the WAL file when SQLite starts it
again. Checkpoint reads SQLite's busy flag and returns
ErrCheckpointBusy, which the maintainer logs as busy, at warning level,
instead of as a completed checkpoint.

Model: opus-5-5
This commit is contained in:
2026-10-19 21:27:52 +00:00
parent 176f9708b8
commit 22f8f4e216
4 changed files with 208 additions and 6 deletions
+30 -4
View File
@@ -81,6 +81,11 @@ const (
sqliteHardHeapLimitBytes = 2147483648
)
// sqliteWALSizeLimitBytes (64 MiB) is the journal_size_limit of the connection
// kept for writing: when SQLite starts the WAL again from the beginning, it cuts
// a larger WAL file down to this size.
const sqliteWALSizeLimitBytes = 67108864
// Settings of the ANALYZE that Analyze runs.
const (
// analysisLimit is about how many rows of each index ANALYZE reads
@@ -112,6 +117,9 @@ var (
ErrNoRoute = errors.New("no route found")
// ErrNoStaleASN is returned when no ASN needs WHOIS refresh
ErrNoStaleASN = errors.New("no stale ASN found")
// ErrCheckpointBusy is returned by Checkpoint when SQLite reports the
// checkpoint busy: another connection was checkpointing at the same time.
ErrCheckpointBusy = errors.New("WAL checkpoint busy")
errInvalidASN = errors.New("invalid ASN")
errSameASNInPeering = errors.New("cannot create peering with same ASN")
@@ -194,6 +202,13 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) {
err)
}
_, err = writer.ExecContext(ctx,
fmt.Sprintf("PRAGMA journal_size_limit=%d", sqliteWALSizeLimitBytes))
if err != nil {
return nil, fmt.Errorf(
"failed to set the writing connection's WAL size limit: %w", err)
}
database := &Database{
db: db, writer: writer, logger: logger, path: dbPath,
counts: &liveCounts{},
@@ -2245,9 +2260,13 @@ func (d *Database) Analyze(ctx context.Context) error {
return nil
}
// Checkpoint runs a WAL checkpoint to transfer data from the WAL to the main database.
// Uses TRUNCATE mode which blocks writers briefly but ensures complete checkpoint,
// keeping the WAL small for fast read performance. It holds the database lock.
// Checkpoint copies the WAL into the database file. It holds the database lock,
// so it runs a PASSIVE checkpoint, which never waits: it copies what no reader
// still needs and leaves the rest for the next one. TRUNCATE or RESTART would
// wait up to the busy timeout for every reader to finish. The WAL file shrinks
// instead when SQLite starts it again from the beginning
// (sqliteWALSizeLimitBytes). It returns ErrCheckpointBusy when SQLite reports
// the checkpoint busy.
func (d *Database) Checkpoint(ctx context.Context) error {
d.lock("Checkpoint")
defer d.unlock()
@@ -2257,11 +2276,18 @@ func (d *Database) Checkpoint(ctx context.Context) error {
return fmt.Errorf("failed to checkpoint WAL: %w", err)
}
_, err = d.writer.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)")
var busy, walPages, checkpointedPages int
err = d.writer.QueryRowContext(ctx, "PRAGMA wal_checkpoint(PASSIVE)").
Scan(&busy, &walPages, &checkpointedPages)
if err != nil {
return fmt.Errorf("failed to checkpoint WAL: %w", err)
}
if busy != 0 {
return ErrCheckpointBusy
}
return nil
}