Repository navigation
fix(store): persistent WAL + version-gated migrations to stop multi-process SQLite corruption #613
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a233761
4cfa25e
3af3051
7df66c4
a5cd6d8
c7cd540
8389e87
dc8042c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package store | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
| "time" | ||
| ) | ||
|
|
||
| // migrationLockTimeout bounds how long a process waits for the migration | ||
| // lock. A hung holder must produce a loud, actionable error instead of | ||
| // silently blocking every engram process on the machine forever. It is a | ||
| // variable (not a constant) so tests can shorten the timeout path. | ||
| var migrationLockTimeout = 60 * time.Second | ||
|
|
||
| // acquireMigrationLock takes an exclusive advisory lock on path and returns | ||
| // a function that releases it. It serializes whole processes around the | ||
| // migration suite and the startup repair so that the destructive | ||
| // check-then-act rebuilds inside migrate() can never run twice concurrently | ||
| // against the same database. | ||
| // | ||
| // Acquisition is non-blocking with a bounded growing backoff (up to | ||
| // migrationLockTimeout total) rather than a blocking lock: a stuck holder | ||
| // then surfaces as a clear error naming the lock file instead of a silent | ||
| // machine-wide hang. | ||
| // | ||
| // The lock file is deliberately left in place after unlock: unlinking it | ||
| // would open a race where a third process re-creates the path and locks a | ||
| // different inode/file object, defeating the exclusion. | ||
| func acquireMigrationLock(path string) (func(), error) { | ||
| f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("open migration lock file %s: %w", path, err) | ||
| } | ||
|
|
||
| deadline := time.Now().Add(migrationLockTimeout) | ||
| backoff := 10 * time.Millisecond | ||
| for { | ||
| acquired, err := tryLockMigrationFile(f) | ||
| if err != nil { | ||
| _ = f.Close() | ||
| return nil, fmt.Errorf("lock migration lock file %s: %w", path, err) | ||
| } | ||
| if acquired { | ||
| return func() { | ||
| _ = unlockMigrationFile(f) | ||
| _ = f.Close() | ||
| }, nil | ||
| } | ||
| if time.Now().After(deadline) { | ||
| _ = f.Close() | ||
| return nil, fmt.Errorf( | ||
| "timed out after %s waiting for migration lock %s — another engram process appears to be holding it; check for a stuck engram process (and terminate it) before retrying", | ||
| migrationLockTimeout, path, | ||
| ) | ||
| } | ||
| time.Sleep(backoff) | ||
| // Grow the poll interval but cap it low: healthy holders release the | ||
| // lock within milliseconds (the startup repair fast path is read-only), | ||
| // and every engram subcommand acquires this lock, so an aggressive cap | ||
| // keeps contended cold starts snappy. | ||
| if backoff < 100*time.Millisecond { | ||
| backoff *= 2 | ||
| if backoff > 100*time.Millisecond { | ||
| backoff = 100 * time.Millisecond | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| //go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: GOOS=aix GOARCH=ppc64 go test -c -o /tmp/engram-store-aix.test ./internal/storeRepository: Gentleman-Programming/engram Length of output: 4543 🏁 Script executed: #!/bin/bash
set -u
printf '%s\n' '--- changed files ---'
git diff --stat -- internal/store/migration_lock_unix.go internal/store/migration_lock_windows.go go.mod
printf '%s\n' '--- unix implementation ---'
cat -n internal/store/migration_lock_unix.go
printf '%s\n' '--- windows sibling ---'
cat -n internal/store/migration_lock_windows.go
printf '%s\n' '--- module metadata ---'
sed -n '1,80p' go.mod
printf '%s\n' '--- lock symbols and callers ---'
rg -n --glob '*.go' 'syscall\.Flock|FcntlFlock|migration_lock|MigrationLock|flock' internal/store
printf '%s\n' '--- local Go toolchain ---'
go version 2>&1 || true
go env GOROOT GOOS GOARCH 2>&1 || true
printf '%s\n' '--- local AIX syscall declarations, if present ---'
GOROOT="$(go env GOROOT 2>/dev/null || true)"
if [ -n "$GOROOT" ] && [ -d "$GOROOT/src/syscall" ]; then
rg -n -C 3 'func Flock|Flock\(' "$GOROOT/src/syscall" "$GOROOT/src/internal/syscall" 2>/dev/null || true
fiRepository: Gentleman-Programming/engram Length of output: 35485 🏁 Script executed: #!/bin/bash
set -u
printf '%s\n' '--- Go 1.25.10 AIX syscall source ---'
for url in \
https://raw.githubusercontent.com/golang/go/go1.25.10/src/syscall/flock_aix.go \
https://raw.githubusercontent.com/golang/go/go1.25.10/src/syscall/syscall_aix.go
do
printf '\nURL: %s\n' "$url"
curl -fsSL "$url" 2>&1 | rg -n -C 4 'flock|Flock|FcntlFlock|no flock|LOCK_EX|LOCK_UN' || true
done
printf '%s\n' '--- x/sys module location ---'
GOMODCACHE="$(go env GOMODCACHE 2>/dev/null || true)"
printf '%s\n' "$GOMODCACHE"
if [ -n "$GOMODCACHE" ]; then
find "$GOMODCACHE/golang.org/x" -maxdepth 2 -type d -name 'sys@v0.43.0' -print 2>/dev/null
fi
printf '%s\n' '--- pinned x/sys AIX lock declarations ---'
XDIR="${GOMODCACHE}/golang.org/x/sys@v0.43.0"
if [ -d "$XDIR/unix" ]; then
rg -n -C 5 'FcntlFlock|Flock|LOCK_EX|LOCK_UN|F_SETLK|F_SETLKW' "$XDIR/unix" -g '*aix*' -g '*.go' 2>/dev/null | head -240
else
printf '%s\n' 'x/sys v0.43.0 is not present in the module cache'
fi
printf '%s\n' '--- target documentation lookup ---'
GOOS=aix GOARCH=ppc64 go doc syscall.Flock 2>&1 || true
GOOS=aix GOARCH=ppc64 go doc syscall.FcntlFlock 2>&1 || trueRepository: Gentleman-Programming/engram Length of output: 27978 🏁 Script executed: #!/bin/bash
set -u
XDIR="$(go env GOMODCACHE)/golang.org/x/sys@v0.43.0/unix"
printf '%s\n' '--- x/sys AIX files ---'
find "$XDIR" -maxdepth 1 -type f -iname '*aix*' -print | sort
printf '%s\n' '--- FcntlFlock definitions in x/sys ---'
rg -n -l '^func FcntlFlock|FcntlFlock' "$XDIR" | sort
rg -n -C 5 '^func FcntlFlock|FcntlFlock' "$XDIR" -g '*aix*' -g 'fcntl*.go' -g 'syscall*.go' 2>/dev/null || true
printf '%s\n' '--- target x/sys documentation ---'
GOOS=aix GOARCH=ppc64 go doc golang.org/x/sys/unix.FcntlFlock 2>&1 || true
GOOS=aix GOARCH=ppc64 go doc golang.org/x/sys/unix.Flock 2>&1 || trueRepository: Gentleman-Programming/engram Length of output: 29403 Fix the AIX build selection. AIX selects 🤖 Prompt for AI AgentsSource: MCP tools |
||
|
|
||
| package store | ||
|
|
||
| import ( | ||
| "errors" | ||
| "os" | ||
| "syscall" | ||
| ) | ||
|
|
||
| // tryLockMigrationFile attempts a non-blocking exclusive flock(2) on f. | ||
| // It reports (false, nil) when another process (or file description) holds | ||
| // the lock, so the caller can retry with backoff. | ||
| func tryLockMigrationFile(f *os.File) (bool, error) { | ||
| err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) | ||
| if err == nil { | ||
| return true, nil | ||
| } | ||
| // EWOULDBLOCK/EAGAIN: lock is held elsewhere. EINTR: interrupted by a | ||
| // signal. Both are retryable, not failures. | ||
| if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EINTR) { | ||
| return false, nil | ||
| } | ||
| return false, err | ||
| } | ||
|
|
||
| // unlockMigrationFile releases the flock taken by tryLockMigrationFile. | ||
| func unlockMigrationFile(f *os.File) error { | ||
| return syscall.Flock(int(f.Fd()), syscall.LOCK_UN) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| //go:build windows | ||
|
|
||
| package store | ||
|
|
||
| import ( | ||
| "errors" | ||
| "os" | ||
|
|
||
| "golang.org/x/sys/windows" | ||
| ) | ||
|
|
||
| // tryLockMigrationFile attempts a non-blocking exclusive LockFileEx on f. | ||
| // It reports (false, nil) when another process holds the lock, so the | ||
| // caller can retry with backoff. | ||
| func tryLockMigrationFile(f *os.File) (bool, error) { | ||
| ol := new(windows.Overlapped) | ||
| err := windows.LockFileEx( | ||
| windows.Handle(f.Fd()), | ||
| windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, | ||
| 0, 1, 0, ol, | ||
| ) | ||
| if err == nil { | ||
| return true, nil | ||
| } | ||
| if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { | ||
| return false, nil | ||
| } | ||
| return false, err | ||
| } | ||
|
|
||
| // unlockMigrationFile releases the lock taken by tryLockMigrationFile. | ||
| func unlockMigrationFile(f *os.File) error { | ||
| return windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, new(windows.Overlapped)) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🌐 Web query:
For modernc.org/sqlite v1.45.0, does a non-nil error returned by RegisterConnectionHook prevent the connection from opening, and what errors can FileControlPersistWAL("main", 1) return?💡 Result:
For modernc.org/sqlite, the behavior of RegisterConnectionHook and FileControlPersistWAL is as follows: 1. RegisterConnectionHook behavior: When a non-nil error is returned by a connection hook, it prevents the connection from being successfully opened [1]. The driver will treat the error as a failure to initialize the connection, which propagates through the sql.Open or sql.DB.Conn calls that triggered the new connection creation. 2. FileControlPersistWAL("main", 1) return values: This function returns the new mode (as an int) and an error (as an error) [2][3][4]. - The error returned by FileControlPersistWAL originates from the underlying SQLite C API function sqlite3_file_control [2]. - If successful, it returns nil as the error and the integer value representing the current persistence state (e.g., 1 if successfully set) [3][4]. - If it fails, it returns a non-nil error indicating the failure (e.g., if the database name is invalid, the operation is unsupported by the VFS, or other SQLite-level errors occur) [2][3]. Typical errors include those mapped from SQLite result codes (e.g., SQLITE_ERROR, SQLITE_MISUSE, or SQLITE_NOTFOUND) [2].
Citations:
🏁 Script executed:
Repository: Gentleman-Programming/engram
Length of output: 4044
🌐 Web query:
modernc.org/sqlite v1.45.0 RegisterConnectionHook source FileControlPersistWAL💡 Result:
In modernc.org/sqlite, both RegisterConnectionHook and FileControlPersistWAL are key features available as of version 1.45.0 and beyond [1][2]. RegisterConnectionHook This function is used to register a callback that executes once for each newly opened database connection, after all Data Source Name (DSN) parameters are applied [2]. It is commonly used for per-connection setup, such as enabling foreign keys, setting PRAGMAs, or registering custom functions [3][2]. - It exists as both a package-level function (modernc.org/sqlite.RegisterConnectionHook) and as a method on the Driver instance (*Driver.RegisterConnectionHook) [2]. - The package-level hook applies to the default driver, while the method-based hook applies to a specific custom driver instance [2]. FileControlPersistWAL This method is part of the FileControl interface, which provides Go access to the underlying sqlite3_file_control interface [4][5]. - Functionality: It is used to set or query the SQLITE_FCNTL_PERSIST_WAL opcode [4][5]. This opcode controls whether the Write-Ahead Log (WAL) file is deleted or persisted after a connection is closed [4][6]. - Usage: It takes the schema name (e.g., "main") and an integer mode (0 for off, 1 for on), and returns the resulting mode or any error encountered [4][5]. - Implementation: In the modernc.org/sqlite implementation, this method interacts with the SQLite core via the TLS (thread-local storage) allocator to safely pass the integer mode to the underlying C-code execution [4][7]. These features are part of the driver's capability to expose low-level SQLite control within a pure-Go environment [4][8].
Citations:
🏁 Script executed:
Repository: Gentleman-Programming/engram
Length of output: 581
🌐 Web query:
site:gitlab.com/cznic/sqlite/-/blob/v1.45.0 RegisterConnectionHook error💡 Result:
In
cznic/sqlitev1.45.0,RegisterConnectionHookitself does not return an error:Errors returned by the hook are wrapped during
Driver.Openas:The hook must therefore have the signature:
[1] (gitlab.com)
Citations:
🏁 Script executed:
Repository: Gentleman-Programming/engram
Length of output: 399
Return persistent-WAL setup failures.
modernc.org/sqlite v1.45.0prevents the connection from opening when the hook returns an error. Return the error fromFileControlPersistWAL("main", 1)so a new pooled connection cannot open without the required persistent-WAL guarantee. Also return an error when the connection does not implementsqlite.FileControl.🤖 Prompt for AI Agents