Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/jackc/pgx/v5 v5.7.6
github.com/mark3labs/mcp-go v0.44.0
golang.org/x/net v0.52.0
golang.org/x/sys v0.43.0
modernc.org/sqlite v1.45.0
)

Expand Down Expand Up @@ -59,7 +60,6 @@ require (
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
68 changes: 68 additions & 0 deletions internal/store/migration_lock.go
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 %q: %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 %q: %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 %q — 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
}
}
}
}
30 changes: 30 additions & 0 deletions internal/store/migration_lock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//go:build unix

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)
}
34 changes: 34 additions & 0 deletions internal/store/migration_lock_windows.go
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))
}
Loading