Skip to content

Commit f72046b

Browse files
localai-botmudler
andauthored
fix(auth): make advisory locks dialect-aware and harden SQLite DSN (#10509)
* fix(auth): make advisory locks dialect-aware and harden SQLite DSN Fixes #10506. Two failures hit deployments that use the default SQLite auth database: 1. advisorylock executed PostgreSQL-only SQL (pg_advisory_lock / pg_try_advisory_lock) unconditionally. On a SQLite auth DB the job store, agent store and node registry migrations failed with "no such function: pg_advisory_lock". WithLockCtx/TryWithLockCtx now branch on the gorm dialect: PostgreSQL keeps the cross-process advisory lock, every other dialect uses a context-aware, per-key in-process lock (a SQLite auth DB is effectively single-process, so serializing within the process is sufficient). 2. The SQLite auth DSN set no busy timeout, so transient SQLITE_BUSY over network-backed storage (SMB/CIFS/NFS, e.g. Azure Files) failed the auth migration immediately with "database is locked". The DSN now sets _busy_timeout=5000 and _txlock=immediate (caller-supplied values are preserved). WAL is intentionally not enabled since its shared-memory mmap does not work over network filesystems. Docs note that PostgreSQL should be used when the data directory lives on shared storage. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * test(jobs): regression test for #10506 SQLite job store migration Exercises the exact caller chain that failed in the issue: auth.InitDB(sqlite) -> jobs.NewJobStore -> advisorylock.WithLockCtx -> AutoMigrate. Before the dialect-aware advisory lock fix this failed with "no such function: pg_advisory_lock"; the test now asserts it migrates cleanly on a SQLite auth DB. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 7978312 commit f72046b

6 files changed

Lines changed: 326 additions & 6 deletions

File tree

core/http/auth/db_sqlite.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,51 @@
33
package auth
44

55
import (
6+
"net/url"
7+
"strings"
8+
69
"gorm.io/driver/sqlite"
710
"gorm.io/gorm"
811
)
912

1013
func openSQLiteDialector(path string) (gorm.Dialector, error) {
11-
return sqlite.Open(path), nil
14+
return sqlite.Open(buildSQLiteDSN(path)), nil
15+
}
16+
17+
// buildSQLiteDSN augments a SQLite file path with connection pragmas that make
18+
// the auth DB resilient on slow or contended storage.
19+
//
20+
// - _busy_timeout=5000 makes SQLite retry for up to 5s on SQLITE_BUSY instead
21+
// of failing immediately. Network-backed storage (SMB/CIFS/NFS, e.g. Azure
22+
// Files) is prone to transient lock contention during migration (see #10506).
23+
// - _txlock=immediate takes the write lock at BEGIN, avoiding deadlocks when a
24+
// read transaction later upgrades to a write during AutoMigrate.
25+
//
26+
// We deliberately do NOT set WAL journal mode: WAL relies on a shared-memory
27+
// mmap that does not work over SMB/NFS, which is exactly the failing case here.
28+
//
29+
// Caller-supplied values for either pragma are preserved.
30+
func buildSQLiteDSN(path string) string {
31+
base := path
32+
rawQuery := ""
33+
if i := strings.IndexByte(path, '?'); i >= 0 {
34+
base = path[:i]
35+
rawQuery = path[i+1:]
36+
}
37+
38+
values, err := url.ParseQuery(rawQuery)
39+
if err != nil {
40+
// An unparseable query string means a hand-crafted DSN we should not
41+
// risk corrupting; leave it untouched.
42+
return path
43+
}
44+
45+
if values.Get("_busy_timeout") == "" {
46+
values.Set("_busy_timeout", "5000")
47+
}
48+
if values.Get("_txlock") == "" {
49+
values.Set("_txlock", "immediate")
50+
}
51+
52+
return base + "?" + values.Encode()
1253
}

core/http/auth/db_sqlite_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//go:build auth
2+
3+
package auth
4+
5+
import (
6+
"net/url"
7+
"strings"
8+
9+
. "github.com/onsi/ginkgo/v2"
10+
. "github.com/onsi/gomega"
11+
)
12+
13+
// parseDSN splits a "base?query" DSN into its base and decoded query values so
14+
// assertions don't depend on url.Values.Encode()'s key ordering.
15+
func parseDSN(dsn string) (string, url.Values) {
16+
base := dsn
17+
rawQuery := ""
18+
if i := strings.IndexByte(dsn, '?'); i >= 0 {
19+
base = dsn[:i]
20+
rawQuery = dsn[i+1:]
21+
}
22+
values, err := url.ParseQuery(rawQuery)
23+
Expect(err).ToNot(HaveOccurred())
24+
return base, values
25+
}
26+
27+
var _ = Describe("buildSQLiteDSN", func() {
28+
It("adds busy_timeout and txlock to a plain file path", func() {
29+
base, values := parseDSN(buildSQLiteDSN("/data/database.db"))
30+
Expect(base).To(Equal("/data/database.db"))
31+
Expect(values.Get("_busy_timeout")).To(Equal("5000"))
32+
Expect(values.Get("_txlock")).To(Equal("immediate"))
33+
})
34+
35+
It("adds pragmas to an in-memory database", func() {
36+
base, values := parseDSN(buildSQLiteDSN(":memory:"))
37+
Expect(base).To(Equal(":memory:"))
38+
Expect(values.Get("_busy_timeout")).To(Equal("5000"))
39+
Expect(values.Get("_txlock")).To(Equal("immediate"))
40+
})
41+
42+
It("preserves an existing query string", func() {
43+
base, values := parseDSN(buildSQLiteDSN("/data/database.db?cache=shared"))
44+
Expect(base).To(Equal("/data/database.db"))
45+
Expect(values.Get("cache")).To(Equal("shared"))
46+
Expect(values.Get("_busy_timeout")).To(Equal("5000"))
47+
Expect(values.Get("_txlock")).To(Equal("immediate"))
48+
})
49+
50+
It("does not override a caller-supplied busy_timeout or txlock", func() {
51+
_, values := parseDSN(buildSQLiteDSN("/data/database.db?_busy_timeout=1000&_txlock=deferred"))
52+
Expect(values["_busy_timeout"]).To(HaveLen(1), "_busy_timeout should not be duplicated")
53+
Expect(values.Get("_busy_timeout")).To(Equal("1000"))
54+
Expect(values["_txlock"]).To(HaveLen(1), "_txlock should not be duplicated")
55+
Expect(values.Get("_txlock")).To(Equal("deferred"))
56+
})
57+
})

core/services/advisorylock/advisorylock.go

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,59 @@ import (
44
"context"
55
"fmt"
66
"hash/fnv"
7+
"strings"
8+
"sync"
79

810
"gorm.io/gorm"
911
)
1012

11-
// TryWithLockCtx attempts to acquire a PostgreSQL advisory lock using the provided context.
12-
// Returns (true, nil) if the lock was acquired and fn executed, (false, nil) if the lock
13-
// was already held, or (false, error) on failure.
13+
// localLocks holds one buffered channel (capacity 1) per lock key, used as an
14+
// in-process mutex for non-PostgreSQL dialects (SQLite). A SQLite auth DB is
15+
// effectively single-process, so serializing guarded sections within this
16+
// process is sufficient - we cannot and need not coordinate across processes
17+
// the way a PostgreSQL advisory lock does.
18+
var (
19+
localLocksMu sync.Mutex
20+
localLocks = map[int64]chan struct{}{}
21+
)
22+
23+
// localLockChan returns the per-key buffered channel, creating it on first use.
24+
func localLockChan(key int64) chan struct{} {
25+
localLocksMu.Lock()
26+
defer localLocksMu.Unlock()
27+
ch, ok := localLocks[key]
28+
if !ok {
29+
ch = make(chan struct{}, 1)
30+
localLocks[key] = ch
31+
}
32+
return ch
33+
}
34+
35+
// isPostgres reports whether the gorm dialect is PostgreSQL. Anything else
36+
// (SQLite and any non-postgres dialect) uses the in-process fallback, because
37+
// the pg_* advisory lock functions only exist on PostgreSQL.
38+
func isPostgres(db *gorm.DB) bool {
39+
return strings.Contains(db.Dialector.Name(), "postgres")
40+
}
41+
42+
// TryWithLockCtx attempts to acquire a lock and run fn without blocking.
43+
// Returns (true, nil) if the lock was acquired and fn executed, (false, nil) if
44+
// the lock was already held, or (false, error) on failure.
45+
//
46+
// On PostgreSQL it uses pg_try_advisory_lock (cross-process). On other dialects
47+
// (SQLite) it uses a non-blocking in-process lock keyed by key.
1448
func TryWithLockCtx(ctx context.Context, db *gorm.DB, key int64, fn func() error) (bool, error) {
49+
if !isPostgres(db) {
50+
ch := localLockChan(key)
51+
select {
52+
case ch <- struct{}{}:
53+
defer func() { <-ch }()
54+
return true, fn()
55+
default:
56+
return false, nil
57+
}
58+
}
59+
1560
sqlDB, err := db.DB()
1661
if err != nil {
1762
return false, fmt.Errorf("get sql.DB: %w", err)
@@ -50,9 +95,31 @@ func KeyFromString(s string) int64 {
5095
return int64(h.Sum64()>>1) | 0x100000000
5196
}
5297

53-
// WithLockCtx is like WithLock but respects context cancellation.
54-
// If ctx is cancelled while waiting for the lock, the function returns ctx.Err().
98+
// WithLockCtx acquires a lock for key, runs fn, then releases it, respecting
99+
// context cancellation. If ctx is cancelled while waiting for the lock, the
100+
// function returns ctx.Err().
101+
//
102+
// On PostgreSQL it uses pg_advisory_lock (cross-process). On other dialects
103+
// (SQLite) it falls back to a blocking in-process lock keyed by key, which is
104+
// sufficient because a SQLite auth DB is effectively single-process.
55105
func WithLockCtx(ctx context.Context, db *gorm.DB, key int64, fn func() error) error {
106+
if !isPostgres(db) {
107+
// Honor an already-cancelled context before attempting acquisition:
108+
// select picks a ready case at random, so without this an already-free
109+
// lock could be taken despite a cancelled ctx.
110+
if err := ctx.Err(); err != nil {
111+
return err
112+
}
113+
ch := localLockChan(key)
114+
select {
115+
case ch <- struct{}{}:
116+
defer func() { <-ch }()
117+
return fn()
118+
case <-ctx.Done():
119+
return ctx.Err()
120+
}
121+
}
122+
56123
sqlDB, err := db.DB()
57124
if err != nil {
58125
return fmt.Errorf("advisorylock: getting sql.DB: %w", err)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package advisorylock
2+
3+
import (
4+
"context"
5+
"sync"
6+
"sync/atomic"
7+
"time"
8+
9+
. "github.com/onsi/ginkgo/v2"
10+
. "github.com/onsi/gomega"
11+
12+
"gorm.io/driver/sqlite"
13+
"gorm.io/gorm"
14+
)
15+
16+
// These specs run against an in-memory SQLite DB and therefore do NOT require
17+
// Docker, unlike the PostgreSQL testcontainer specs.
18+
var _ = Describe("AdvisoryLock (SQLite fallback)", Label("sqlite"), func() {
19+
var db *gorm.DB
20+
21+
BeforeEach(func() {
22+
var err error
23+
db, err = gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
24+
Expect(err).ToNot(HaveOccurred())
25+
Expect(db.Dialector.Name()).To(ContainSubstring("sqlite"))
26+
})
27+
28+
It("WithLockCtx executes fn and returns no error on SQLite", func() {
29+
const lockKey int64 = 12001
30+
executed := false
31+
32+
err := WithLockCtx(context.Background(), db, lockKey, func() error {
33+
executed = true
34+
return nil
35+
})
36+
Expect(err).ToNot(HaveOccurred())
37+
Expect(executed).To(BeTrue(), "function should have run under the in-process lock")
38+
})
39+
40+
It("WithLockCtx serializes concurrent goroutines on the same key", func() {
41+
const lockKey int64 = 12002
42+
43+
var (
44+
mu sync.Mutex
45+
maxRunning int32
46+
running int32
47+
concurrency int32
48+
)
49+
50+
var wg sync.WaitGroup
51+
52+
for range 2 {
53+
wg.Go(func() {
54+
defer GinkgoRecover()
55+
err := WithLockCtx(context.Background(), db, lockKey, func() error {
56+
cur := atomic.AddInt32(&running, 1)
57+
mu.Lock()
58+
if cur > maxRunning {
59+
maxRunning = cur
60+
}
61+
if cur > 1 {
62+
atomic.AddInt32(&concurrency, 1)
63+
}
64+
mu.Unlock()
65+
66+
time.Sleep(50 * time.Millisecond)
67+
68+
atomic.AddInt32(&running, -1)
69+
return nil
70+
})
71+
Expect(err).ToNot(HaveOccurred())
72+
})
73+
}
74+
75+
wg.Wait()
76+
77+
Expect(maxRunning).To(BeNumerically("<=", 1), "expected max 1 goroutine inside lock at a time")
78+
Expect(concurrency).To(BeZero(), "detected concurrent execution inside advisory lock")
79+
})
80+
81+
It("WithLockCtx returns an error and does not run fn with an already-cancelled context", func() {
82+
const lockKey int64 = 12003
83+
ctx, cancel := context.WithCancel(context.Background())
84+
cancel()
85+
86+
err := WithLockCtx(ctx, db, lockKey, func() error {
87+
Fail("function should not run with a cancelled context")
88+
return nil
89+
})
90+
Expect(err).To(HaveOccurred())
91+
})
92+
93+
It("TryWithLockCtx returns (true, nil) when free and (false, nil) when held", func() {
94+
const lockKey int64 = 12004
95+
96+
acquired, err := TryWithLockCtx(context.Background(), db, lockKey, func() error {
97+
return nil
98+
})
99+
Expect(err).ToNot(HaveOccurred())
100+
Expect(acquired).To(BeTrue(), "expected TryWithLockCtx to acquire the free lock")
101+
102+
// Hold the lock in one goroutine while a concurrent TryWithLockCtx
103+
// attempts to acquire the same key.
104+
held := make(chan struct{})
105+
release := make(chan struct{})
106+
var wg sync.WaitGroup
107+
wg.Go(func() {
108+
defer GinkgoRecover()
109+
ok, err := TryWithLockCtx(context.Background(), db, lockKey, func() error {
110+
close(held)
111+
<-release
112+
return nil
113+
})
114+
Expect(err).ToNot(HaveOccurred())
115+
Expect(ok).To(BeTrue())
116+
})
117+
118+
<-held
119+
ok, err := TryWithLockCtx(context.Background(), db, lockKey, func() error {
120+
Fail("function should not run while lock is held")
121+
return nil
122+
})
123+
Expect(err).ToNot(HaveOccurred())
124+
Expect(ok).To(BeFalse(), "expected TryWithLockCtx to fail to acquire a held lock")
125+
126+
close(release)
127+
wg.Wait()
128+
})
129+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build auth
2+
3+
package jobs_test
4+
5+
import (
6+
"github.com/mudler/LocalAI/core/http/auth"
7+
"github.com/mudler/LocalAI/core/services/jobs"
8+
9+
. "github.com/onsi/ginkgo/v2"
10+
. "github.com/onsi/gomega"
11+
)
12+
13+
// Reproduces the #10506 caller chain: auth.InitDB(sqlite) -> jobs.NewJobStore,
14+
// which previously failed with "no such function: pg_advisory_lock".
15+
var _ = Describe("NewJobStore on a SQLite auth DB (#10506)", func() {
16+
It("migrates without pg_advisory_lock errors", func() {
17+
db, err := auth.InitDB(":memory:")
18+
Expect(err).ToNot(HaveOccurred())
19+
20+
store, err := jobs.NewJobStore(db)
21+
Expect(err).ToNot(HaveOccurred())
22+
Expect(store).ToNot(BeNil())
23+
})
24+
})

docs/content/features/authentication.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ localai run
8585
| `LOCALAI_REGISTRATION_MODE` | `approval` | Registration mode: `open`, `approval`, or `invite` |
8686
| `LOCALAI_DISABLE_LOCAL_AUTH` | `false` | Disable local email/password registration and login (for OAuth/OIDC-only deployments) |
8787

88+
> **Note: network-backed storage.** File-based SQLite relies on POSIX file locking, which is unreliable over network filesystems (SMB/CIFS/NFS, e.g. Azure Files / Azure Container Apps shared volumes). On such storage the auth DB can fail to migrate with `database is locked`. Use PostgreSQL (`LOCALAI_AUTH_DATABASE_URL=postgres://...`) when the data directory lives on shared or network storage, or place `database.db` on a local volume.
89+
8890
### Disabling Local Authentication
8991

9092
If you want to enforce OAuth/OIDC-only login and prevent users from registering or logging in with email/password, set `LOCALAI_DISABLE_LOCAL_AUTH=true` (or pass `--disable-local-auth`):

0 commit comments

Comments
 (0)