Found while investigating #329 (MQTT opening its own SQLite connection). #329 is not an isolated case — it is one of six long-lived handles to the same physical file.
The shared file
cfg.Auth.DBPath (default ./data/arc.db) is opened independently by:
| Component |
Where |
Pool settings |
Closed? |
auth.AuthManager |
internal/auth/auth.go:188 |
MaxOpenConns(1), MaxIdleConns(1), ConnMaxLifetime(1h), DSN with WAL + busy_timeout + foreign_keys |
Yes — legitimate owner |
mqtt.Repository |
internal/mqtt/repository.go:40 |
MaxOpenConns(1), MaxIdleConns(1), DSN with WAL + busy_timeout |
Yes (own handle) — #329 |
audit logger |
cmd/arc/main.go:1858 |
none — unbounded pool, bare path, no DSN params |
NO — leaked |
tiering manager |
cmd/arc/main.go:2467 |
none — unbounded pool, bare path, no DSN params |
NO — leaked |
api.RetentionHandler |
internal/api/retention.go:114 |
none |
Yes |
api.ContinuousQueryHandler |
internal/api/continuous_query.go:157 |
none |
Yes |
This issue: audit and tiering specifically
These two are the cheapest to fix and the only ones with a leak:
-
The packages are already correct. audit.LoggerConfig.DB (internal/audit/audit.go:72) and tiering.ManagerConfig.DB (internal/tiering/manager.go:56) both accept a borrowed *sql.DB and neither closes it in Stop(). Only main.go is wrong — it opens a redundant handle instead of passing authManager.GetDB().
-
Both handles are leaked. There is no auditDB.Close() or tieringDB.Close() anywhere in main.go. audit.Logger.Stop() (audit.go:157) and tiering.Manager.Stop() (manager.go:166) correctly do not close a borrowed DB — but nothing else closes these owned ones either. They stay open for the process lifetime.
-
No pool limits. Unlike auth and MQTT, these use sql.Open with a bare path — no _busy_timeout, no _journal_mode=WAL, and default MaxOpenConns=0 (unlimited). Multiple connections from these pools can contend for SQLite's single write lock with no busy-timeout backoff, making SQLITE_BUSY more likely than for the handles that do set it.
Fix
Pass authManager.GetDB() at both sites — a main.go-only change.
Guard required: both blocks sit inside licenseClient != nil checks, and neither implies authManager != nil. authManager is only constructed under cfg.Auth.Enabled (main.go:933), and GetDB() on a nil receiver panics. Each site needs its own nil-guard with a documented fallback (open a standalone handle, as today). This is the CLAUDE.md "independently enabled subsystems" shape.
Not in scope here
retention.db_path and continuous_query.db_path are separate config keys (internal/config/config.go:985,989) that merely default to the same file. An operator pointing them elsewhere today gets a genuinely separate database; converting them to borrow the auth DB would silently make those keys meaningless. That needs a deprecation decision, not a refactor — worth its own issue.
Related
Found while investigating #329 (MQTT opening its own SQLite connection). #329 is not an isolated case — it is one of six long-lived handles to the same physical file.
The shared file
cfg.Auth.DBPath(default./data/arc.db) is opened independently by:auth.AuthManagerinternal/auth/auth.go:188MaxOpenConns(1),MaxIdleConns(1),ConnMaxLifetime(1h), DSN with WAL + busy_timeout + foreign_keysmqtt.Repositoryinternal/mqtt/repository.go:40MaxOpenConns(1),MaxIdleConns(1), DSN with WAL + busy_timeoutauditloggercmd/arc/main.go:1858tieringmanagercmd/arc/main.go:2467api.RetentionHandlerinternal/api/retention.go:114api.ContinuousQueryHandlerinternal/api/continuous_query.go:157This issue: audit and tiering specifically
These two are the cheapest to fix and the only ones with a leak:
The packages are already correct.
audit.LoggerConfig.DB(internal/audit/audit.go:72) andtiering.ManagerConfig.DB(internal/tiering/manager.go:56) both accept a borrowed*sql.DBand neither closes it inStop(). Onlymain.gois wrong — it opens a redundant handle instead of passingauthManager.GetDB().Both handles are leaked. There is no
auditDB.Close()ortieringDB.Close()anywhere inmain.go.audit.Logger.Stop()(audit.go:157) andtiering.Manager.Stop()(manager.go:166) correctly do not close a borrowed DB — but nothing else closes these owned ones either. They stay open for the process lifetime.No pool limits. Unlike auth and MQTT, these use
sql.Openwith a bare path — no_busy_timeout, no_journal_mode=WAL, and defaultMaxOpenConns=0(unlimited). Multiple connections from these pools can contend for SQLite's single write lock with no busy-timeout backoff, makingSQLITE_BUSYmore likely than for the handles that do set it.Fix
Pass
authManager.GetDB()at both sites — amain.go-only change.Guard required: both blocks sit inside
licenseClient != nilchecks, and neither impliesauthManager != nil.authManageris only constructed undercfg.Auth.Enabled(main.go:933), andGetDB()on a nil receiver panics. Each site needs its own nil-guard with a documented fallback (open a standalone handle, as today). This is the CLAUDE.md "independently enabled subsystems" shape.Not in scope here
retention.db_pathandcontinuous_query.db_pathare separate config keys (internal/config/config.go:985,989) that merely default to the same file. An operator pointing them elsewhere today gets a genuinely separate database; converting them to borrow the auth DB would silently make those keys meaningless. That needs a deprecation decision, not a refactor — worth its own issue.Related
cfg.Auth.DBPathdirectly with no config key of its own, so it converts cleanly)