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.
251 lines
6.7 KiB
Go
251 lines
6.7 KiB
Go
package datadir_test
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/datadir"
|
|
)
|
|
|
|
// holderEnv names the directory the re-executed test binary should
|
|
// lock and hold. When it is unset the child test does nothing, so an
|
|
// ordinary run is unaffected.
|
|
const holderEnv = "WEBHOOKER_DATADIR_LOCK_HOLDER"
|
|
|
|
// holderReadyPrefix labels the child's one-line report that it holds
|
|
// the lock, so the parent can find it among the testing package's own
|
|
// output on the same descriptor.
|
|
const holderReadyPrefix = "DATADIR-LOCK-HELD "
|
|
|
|
// holderReadyTimeout bounds the wait for the child to take the lock.
|
|
// It only has to cover process start on a loaded shared host.
|
|
const holderReadyTimeout = 60 * time.Second
|
|
|
|
// holderHold is how long the child keeps the lock if nothing kills it.
|
|
// A sleep rather than a bare block, so the runtime's deadlock detector
|
|
// has a pending timer and the child cannot outlive a killed test run
|
|
// by more than this.
|
|
const holderHold = 10 * time.Minute
|
|
|
|
// TestLockHolder is the child half of the two-process tests below. It
|
|
// takes the lock on the directory named by holderEnv, reports the lock
|
|
// file on standard output, and then holds it until it is killed.
|
|
func TestLockHolder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := os.Getenv(holderEnv)
|
|
if dir == "" {
|
|
return
|
|
}
|
|
|
|
lock, err := datadir.Acquire(dir)
|
|
require.NoError(t, err)
|
|
|
|
// Written to the descriptor directly: the parent reads fd 1, not
|
|
// the testing package's buffered report.
|
|
_, err = fmt.Fprintf(
|
|
os.Stdout, "%s%s\n", holderReadyPrefix, lock.Path(),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
time.Sleep(holderHold)
|
|
}
|
|
|
|
// startHolder re-executes this test binary as a separate process that
|
|
// takes and holds the lock on dir, and returns once that process
|
|
// actually holds it. The child is killed when the test ends.
|
|
func startHolder(t *testing.T, dir string) *exec.Cmd {
|
|
t.Helper()
|
|
|
|
//nolint:gosec // Re-executing this test binary, with a fixed arg.
|
|
cmd := exec.CommandContext(
|
|
t.Context(), os.Args[0], "-test.run", "^TestLockHolder$",
|
|
)
|
|
|
|
cmd.Env = append(os.Environ(), holderEnv+"="+dir)
|
|
cmd.Stderr = os.Stderr
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
require.NoError(t, err)
|
|
require.NoError(t, cmd.Start())
|
|
|
|
t.Cleanup(func() {
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
})
|
|
|
|
ready := make(chan string, 1)
|
|
|
|
go func() {
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
after, found := strings.CutPrefix(
|
|
scanner.Text(), holderReadyPrefix,
|
|
)
|
|
if found {
|
|
ready <- after
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
close(ready)
|
|
|
|
// Keep draining so the child never blocks on a full pipe.
|
|
_, _ = io.Copy(io.Discard, stdout)
|
|
}()
|
|
|
|
select {
|
|
case path, ok := <-ready:
|
|
require.True(
|
|
t, ok, "holder exited without taking the lock",
|
|
)
|
|
require.Equal(t, filepath.Join(dir, datadir.LockFileName), path)
|
|
case <-time.After(holderReadyTimeout):
|
|
t.Fatal("timed out waiting for the holder to take the lock")
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
// TestSecondInstanceRefused is the regression test for the duplicate
|
|
// delivery this package exists to prevent: a real second process
|
|
// pointed at a data directory a live process already holds must be
|
|
// refused, with an error that names the directory.
|
|
func TestSecondInstanceRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
startHolder(t, dir)
|
|
|
|
lock, err := datadir.Acquire(dir)
|
|
|
|
require.Error(t, err, "the second instance took the lock too")
|
|
require.Nil(t, lock)
|
|
require.ErrorIs(
|
|
t, err, datadir.ErrLocked,
|
|
"the refusal must be distinguishable from any other failure",
|
|
)
|
|
assert.Contains(
|
|
t, err.Error(), dir,
|
|
"the refusal must name the directory it is about",
|
|
)
|
|
}
|
|
|
|
// TestRestartAfterHardKill is the other half of the regression: a
|
|
// process killed with SIGKILL runs no cleanup and leaves its lock file
|
|
// behind, and the next start must not be blocked by it. This is what a
|
|
// pidfile would get wrong; the kernel drops a flock when the
|
|
// descriptor closes, however the process died.
|
|
func TestRestartAfterHardKill(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
holder := startHolder(t, dir)
|
|
|
|
require.NoError(t, holder.Process.Kill())
|
|
|
|
// Wait for the kill to have actually happened. Re-acquiring while
|
|
// the corpse still holds a descriptor would be a race, and would
|
|
// make this test pass or fail on scheduling.
|
|
_ = holder.Wait()
|
|
|
|
require.FileExists(
|
|
t, filepath.Join(dir, datadir.LockFileName),
|
|
"the stale lock file is what must not block the restart",
|
|
)
|
|
|
|
lock, err := datadir.Acquire(dir)
|
|
require.NoError(
|
|
t, err, "a hard-killed instance must not block the next start",
|
|
)
|
|
|
|
require.NoError(t, lock.Release())
|
|
}
|
|
|
|
// TestSecondFdInSameProcessRefused pins the flock(2) property the
|
|
// tests in cmd/webhooker rely on: descriptors are locked
|
|
// independently, so a second acquisition is denied even when it comes
|
|
// from the process that already holds the lock.
|
|
func TestSecondFdInSameProcessRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
first, err := datadir.Acquire(dir)
|
|
require.NoError(t, err)
|
|
|
|
defer func() { _ = first.Release() }()
|
|
|
|
_, err = datadir.Acquire(dir)
|
|
require.ErrorIs(t, err, datadir.ErrLocked)
|
|
}
|
|
|
|
// TestReleaseAllowsReacquire covers the clean-shutdown path: the lock
|
|
// is released on exit, so a restart is not blocked by the previous
|
|
// run.
|
|
func TestReleaseAllowsReacquire(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
first, err := datadir.Acquire(dir)
|
|
require.NoError(t, err)
|
|
require.NoError(t, first.Release())
|
|
|
|
second, err := datadir.Acquire(dir)
|
|
require.NoError(t, err)
|
|
require.NoError(t, second.Release())
|
|
}
|
|
|
|
// TestAcquireCreatesDataDir covers a first start against a DATA_DIR
|
|
// that does not exist yet, which is the normal case for a fresh
|
|
// deployment: the lock is taken before anything else creates it.
|
|
func TestAcquireCreatesDataDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := filepath.Join(t.TempDir(), "nested", "data")
|
|
|
|
lock, err := datadir.Acquire(dir)
|
|
require.NoError(t, err)
|
|
|
|
defer func() { _ = lock.Release() }()
|
|
|
|
assert.Equal(t, dir, lock.Dir())
|
|
assert.FileExists(t, filepath.Join(dir, datadir.LockFileName))
|
|
}
|
|
|
|
// TestAcquireEmptyDir rejects an empty directory rather than locking
|
|
// the process's working directory.
|
|
func TestAcquireEmptyDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := datadir.Acquire("")
|
|
require.ErrorIs(t, err, datadir.ErrNoDir)
|
|
}
|
|
|
|
// TestAcquireUnusableDir reports an unusable DATA_DIR clearly, naming
|
|
// it, instead of failing later and deeper.
|
|
func TestAcquireUnusableDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
file := filepath.Join(t.TempDir(), "not-a-directory")
|
|
require.NoError(t, os.WriteFile(file, nil, 0o600))
|
|
|
|
_, err := datadir.Acquire(file)
|
|
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), file)
|
|
}
|