Take an exclusive lock on DATA_DIR at startup (closes #201) (#220)
Some checks failed
check / check (push) Superseded by a newer commit; never tested

This commit was merged in pull request #220.
This commit is contained in:
2026-08-20 07:23:00 +02:00
parent 5af161ef60
commit c6a9884f86
9 changed files with 542 additions and 22 deletions

View File

@@ -127,6 +127,40 @@ empty string, and quietly withholding it would deny an endpoint that
was asked for. The `hasMetricsAuth` field in the startup log and the
existence of the route are the same value, so they cannot disagree.
#### Single-instance lock
Exactly one webhooker process may use a `DATA_DIR` at a time. Two
processes sharing one open the same databases and each run delivery
recovery over the same rows, so every pending delivery goes out twice —
duplicate delivery to your endpoints, from nothing worse than an
overlapping deploy or a double start.
At startup, before anything opens a database, the process takes an
exclusive advisory lock (`flock(2)`) on `{DATA_DIR}/webhooker.lock` and
holds it for its lifetime. A second process pointed at the same
directory prints a message naming it and exits non-zero:
```
webhooker: data directory is already in use by another instance: /var/lib/webhooker (/var/lib/webhooker/webhooker.lock). Only one webhooker may use a data directory: two both run delivery recovery over the same rows and both deliver
```
The lock is the kernel's, not the file's: it is released when the
process exits, including `kill -9`, so a leftover `webhooker.lock`
never blocks a restart and must not be deleted by hand. The file is
also left in place on a clean shutdown, deliberately — unlinking it
would let the next process lock a fresh inode while a third still held
the old one.
To run two webhookers on one host, give each its own `DATA_DIR`.
`flock(2)` is host-local and per-inode: it arbitrates between processes
and containers sharing a volume or bind mount on one machine, but not
between hosts on a network filesystem, and a `DATA_DIR` inside a
container's own writable layer is not shared with anything. On a
filesystem that refuses `flock` outright, startup fails closed — the
process reports the error and refuses to start rather than running
unlocked.
#### Trusted proxies
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
@@ -340,10 +374,12 @@ is both the simplest and the only complete rule:
`{webhook_uuid}` is the webhook's UUID primary key in its canonical
36-character hyphenated form, so a real filename looks like
`events-3f2a1c9e-....db`. Nothing else is written to `DATA_DIR`, and no
`-wal` or `-shm` files are produced (see below); a transient
`{name}.db-journal` may exist beside a database while a write is in
flight and is not part of the backup set.
`events-3f2a1c9e-....db`. The only other file is `webhooker.lock`, the
always-empty [single-instance lock](#single-instance-lock); it holds no
state and is not part of the backup set — a copied one is stale and
blocks nothing. No `-wal` or `-shm` files are produced (see below); a
transient `{name}.db-journal` may exist beside a database while a write
is in flight and is not part of the backup set either.
Configuration is **not** in `DATA_DIR` — it comes from the environment
and from a `.env` file read out of the process working directory. Back
@@ -1830,7 +1866,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
```
webhooker/
├── cmd/webhooker/
│ └── main.go # Entry point: sets globals, wires fx
│ └── main.go # Entry point: sets globals, locks DATA_DIR, wires fx
├── internal/
│ ├── config/
│ │ └── config.go # Configuration loading from environment variables
@@ -1851,6 +1887,8 @@ webhooker/
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
│ │ ├── testing.go # NewTestDatabase: wrapper for tests, no fx lifecycle
│ │ └── webhook_db_manager.go # Per-webhook DB lifecycle manager
│ ├── datadir/
│ │ └── lock.go # Exclusive advisory lock on DATA_DIR (one instance)
│ ├── globals/
│ │ └── globals.go # Build-time variables (appname, version, arch)
│ ├── gormlog/