Lock DATA_DIR against a second instance (closes #201)
All checks were successful
check / check (push) Successful in 6m35s

Nothing stopped two processes opening the same DATA_DIR. Both open the
same per-webhook databases, both run delivery recovery over the same
rows, and both deliver: every pending delivery reaches the destination
twice, from nothing worse than an overlapping deploy.

The entry point now takes an exclusive advisory flock(2) on
{DATA_DIR}/webhooker.lock before anything opens a database, and holds it
for the process lifetime. A second process pointed at the same directory
prints a message naming that directory and exits 1. The lock is the
kernel's, not the file's, so a process killed with SIGKILL leaves a lock
file that blocks nothing -- which is what a pidfile would get wrong. The
file is never unlinked: doing so would let the next process lock a fresh
inode while a third still held the old one.

Acquisition lives in internal/datadir rather than in the server's fx
graph, so any entry point touching DATA_DIR takes it the same way, and
ErrLocked lets a caller tell a live deployment from any other failure.
config.DataDir() resolves DATA_DIR once, for both the lock and Config,
so the two cannot disagree.

Regression coverage: a real second process is refused, and a restart
after kill -9 succeeds with the stale lock file in place.

github.com/gofrs/flock carries the lock; its own module minimums pull
testify to v1.11.1 and golang.org/x/sys to v0.37.0.
This commit is contained in:
2026-08-20 04:13:56 +00:00
parent 10c8dd2331
commit 445ef57ada
9 changed files with 534 additions and 22 deletions

View File

@@ -26,6 +26,10 @@ const (
// EnvironmentProd represents production environment.
EnvironmentProd = "prod"
// DefaultDataDir is where all SQLite databases live when DATA_DIR
// is unset. The same default applies in every environment.
DefaultDataDir = "/var/lib/webhooker"
// defaultPort is the default HTTP listen port.
defaultPort = 8080
@@ -134,6 +138,19 @@ func envString(key string) string {
return os.Getenv(key)
}
// DataDir resolves DATA_DIR, applying DefaultDataDir when it is unset
// or empty. It is exported so that entry points which must act on the
// data directory before the fx graph exists — taking the exclusive
// directory lock, above all — resolve it exactly as Config does.
func DataDir() string {
dir := envString("DATA_DIR")
if dir == "" {
return DefaultDataDir
}
return dir
}
// envBool returns the value of the named environment variable
// parsed as a boolean. Returns defaultValue if not set. If the
// variable is set but cannot be parsed, it returns a wrapped error
@@ -407,7 +424,7 @@ func loadFromEnv() (*Config, error) {
}
return &Config{
DataDir: envString("DATA_DIR"),
DataDir: DataDir(),
Debug: debug,
MaintenanceMode: maintenanceMode,
Environment: environment,
@@ -485,14 +502,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
s.log = log
s.params = &params
// Set default DataDir. All SQLite databases (main application
// DB and per-webhook event DBs) live here. The same default is
// used regardless of environment; override with DATA_DIR if
// needed.
if s.DataDir == "" {
s.DataDir = "/var/lib/webhooker"
}
if s.Debug {
params.Logger.EnableDebugLogging()
}