Skip to content

Commit c6bb7da

Browse files
committed
refactoring and consolidation
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 33ba120 commit c6bb7da

38 files changed

Lines changed: 2610 additions & 565 deletions

core/services/advisorylock/advisorylock.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,32 @@ func KeyFromUUID(b [16]byte) int64 {
8484
h.Write(b[:])
8585
return int64(h.Sum64()>>1) | 0x100000000
8686
}
87+
88+
// KeyFromString converts an arbitrary string to an advisory lock key via FNV-1a hashing.
89+
func KeyFromString(s string) int64 {
90+
h := fnv.New64a()
91+
h.Write([]byte(s))
92+
return int64(h.Sum64()>>1) | 0x100000000
93+
}
94+
95+
// WithLockCtx is like WithLock but respects context cancellation.
96+
// If ctx is cancelled while waiting for the lock, the function returns ctx.Err().
97+
func WithLockCtx(ctx context.Context, db *gorm.DB, key int64, fn func() error) error {
98+
sqlDB, err := db.DB()
99+
if err != nil {
100+
return fmt.Errorf("advisorylock: getting sql.DB: %w", err)
101+
}
102+
conn, err := sqlDB.Conn(ctx)
103+
if err != nil {
104+
return fmt.Errorf("advisorylock: getting connection: %w", err)
105+
}
106+
defer conn.Close()
107+
108+
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", key); err != nil {
109+
return fmt.Errorf("advisorylock: acquiring lock %d: %w", key, err)
110+
}
111+
// Always release the lock, even if ctx is cancelled
112+
defer conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", key)
113+
114+
return fn()
115+
}

core/services/advisorylock/advisorylock_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package advisorylock
22

33
import (
4+
"context"
45
"runtime"
56
"sync"
67
"sync/atomic"
@@ -108,5 +109,95 @@ var _ = Describe("AdvisoryLock", func() {
108109

109110
Expect(keys).To(HaveLen(6), "some advisory lock keys have the same value")
110111
})
112+
113+
It("KeyFromString is deterministic", func() {
114+
k1 := KeyFromString("foo")
115+
k2 := KeyFromString("foo")
116+
Expect(k1).To(Equal(k2), "KeyFromString should return the same value for the same input")
117+
})
118+
119+
It("KeyFromString returns different keys for different inputs", func() {
120+
kFoo := KeyFromString("foo")
121+
kBar := KeyFromString("bar")
122+
Expect(kFoo).ToNot(Equal(kBar), "KeyFromString should return different keys for different inputs")
123+
})
124+
})
125+
126+
Context("WithLockCtx (PostgreSQL)", func() {
127+
var db *gorm.DB
128+
129+
BeforeEach(func() {
130+
if runtime.GOOS == "darwin" {
131+
Skip("testcontainers requires Docker, not available on macOS CI")
132+
}
133+
db = testutil.SetupTestDB()
134+
})
135+
136+
It("acquires lock and executes the function", func() {
137+
const lockKey int64 = 700
138+
executed := false
139+
140+
err := WithLockCtx(context.Background(), db, lockKey, func() error {
141+
executed = true
142+
return nil
143+
})
144+
Expect(err).ToNot(HaveOccurred())
145+
Expect(executed).To(BeTrue(), "function should have been executed under lock")
146+
})
147+
148+
It("returns error when context is already cancelled", func() {
149+
const lockKey int64 = 701
150+
ctx, cancel := context.WithCancel(context.Background())
151+
cancel() // cancel immediately
152+
153+
err := WithLockCtx(ctx, db, lockKey, func() error {
154+
Fail("function should not run with cancelled context")
155+
return nil
156+
})
157+
Expect(err).To(HaveOccurred())
158+
})
159+
160+
It("serializes concurrent WithLockCtx on same key", func() {
161+
const lockKey int64 = 702
162+
163+
var (
164+
mu sync.Mutex
165+
maxRunning int32
166+
running int32
167+
concurrency int32
168+
)
169+
170+
var wg sync.WaitGroup
171+
wg.Add(2)
172+
173+
for i := 0; i < 2; i++ {
174+
go func() {
175+
defer GinkgoRecover()
176+
defer wg.Done()
177+
err := WithLockCtx(context.Background(), db, lockKey, func() error {
178+
cur := atomic.AddInt32(&running, 1)
179+
mu.Lock()
180+
if cur > maxRunning {
181+
maxRunning = cur
182+
}
183+
if cur > 1 {
184+
atomic.AddInt32(&concurrency, 1)
185+
}
186+
mu.Unlock()
187+
188+
time.Sleep(50 * time.Millisecond)
189+
190+
atomic.AddInt32(&running, -1)
191+
return nil
192+
})
193+
Expect(err).ToNot(HaveOccurred())
194+
}()
195+
}
196+
197+
wg.Wait()
198+
199+
Expect(maxRunning).To(BeNumerically("<=", 1), "expected max 1 goroutine inside lock at a time")
200+
Expect(concurrency).To(BeZero(), "detected concurrent execution inside advisory lock")
201+
})
111202
})
112203
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package advisorylock
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/mudler/xlog"
8+
"gorm.io/gorm"
9+
)
10+
11+
// RunLeaderLoop runs fn on a fixed interval, guarded by a PostgreSQL advisory lock.
12+
// Only one instance across the cluster executes fn at a time. If the lock is not
13+
// acquired (another instance holds it), the tick is skipped.
14+
// The loop stops when ctx is cancelled.
15+
func RunLeaderLoop(ctx context.Context, db *gorm.DB, lockKey int64, interval time.Duration, fn func()) {
16+
ticker := time.NewTicker(interval)
17+
defer ticker.Stop()
18+
19+
for {
20+
select {
21+
case <-ctx.Done():
22+
return
23+
case <-ticker.C:
24+
_, err := TryWithLock(db, lockKey, func() error {
25+
fn()
26+
return nil
27+
})
28+
if err != nil {
29+
xlog.Error("Leader loop advisory lock error", "key", lockKey, "error", err)
30+
}
31+
}
32+
}
33+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package advisorylock
2+
3+
import (
4+
"context"
5+
"runtime"
6+
"sync"
7+
"sync/atomic"
8+
"time"
9+
10+
. "github.com/onsi/ginkgo/v2"
11+
. "github.com/onsi/gomega"
12+
13+
"github.com/mudler/LocalAI/core/services/testutil"
14+
)
15+
16+
var _ = Describe("RunLeaderLoop", func() {
17+
Context("PostgreSQL leader election", func() {
18+
BeforeEach(func() {
19+
if runtime.GOOS == "darwin" {
20+
Skip("testcontainers requires Docker, not available on macOS CI")
21+
}
22+
})
23+
24+
It("executes function on tick", func() {
25+
db := testutil.SetupTestDB()
26+
const lockKey int64 = 5000
27+
28+
var callCount int32
29+
ctx, cancel := context.WithCancel(context.Background())
30+
defer cancel()
31+
32+
go RunLeaderLoop(ctx, db, lockKey, 50*time.Millisecond, func() {
33+
atomic.AddInt32(&callCount, 1)
34+
})
35+
36+
Eventually(func() int32 {
37+
return atomic.LoadInt32(&callCount)
38+
}, 500*time.Millisecond, 10*time.Millisecond).Should(BeNumerically(">=", 1),
39+
"expected function to be called at least once")
40+
})
41+
42+
It("stops when context is cancelled", func() {
43+
db := testutil.SetupTestDB()
44+
const lockKey int64 = 5001
45+
46+
var callCount int32
47+
ctx, cancel := context.WithCancel(context.Background())
48+
49+
done := make(chan struct{})
50+
go func() {
51+
RunLeaderLoop(ctx, db, lockKey, 50*time.Millisecond, func() {
52+
atomic.AddInt32(&callCount, 1)
53+
})
54+
close(done)
55+
}()
56+
57+
// Let it run a bit then cancel
58+
time.Sleep(150 * time.Millisecond)
59+
cancel()
60+
61+
// RunLeaderLoop should return
62+
Eventually(done, 500*time.Millisecond).Should(BeClosed())
63+
64+
// Record count after cancellation
65+
countAfterCancel := atomic.LoadInt32(&callCount)
66+
time.Sleep(150 * time.Millisecond)
67+
countLater := atomic.LoadInt32(&callCount)
68+
69+
Expect(countLater).To(Equal(countAfterCancel),
70+
"function should stop being called after context cancellation")
71+
})
72+
73+
It("only one leader executes at a time (two concurrent loops)", func() {
74+
db := testutil.SetupTestDB()
75+
const lockKey int64 = 5002
76+
77+
var (
78+
mu sync.Mutex
79+
maxRunning int32
80+
running int32
81+
)
82+
83+
ctx, cancel := context.WithCancel(context.Background())
84+
defer cancel()
85+
86+
fn := func() {
87+
cur := atomic.AddInt32(&running, 1)
88+
mu.Lock()
89+
if cur > maxRunning {
90+
maxRunning = cur
91+
}
92+
mu.Unlock()
93+
94+
time.Sleep(30 * time.Millisecond)
95+
96+
atomic.AddInt32(&running, -1)
97+
}
98+
99+
// Start two competing leader loops with the same lock key
100+
go RunLeaderLoop(ctx, db, lockKey, 50*time.Millisecond, fn)
101+
go RunLeaderLoop(ctx, db, lockKey, 50*time.Millisecond, fn)
102+
103+
// Let them run for a while
104+
time.Sleep(400 * time.Millisecond)
105+
cancel()
106+
107+
mu.Lock()
108+
observed := maxRunning
109+
mu.Unlock()
110+
111+
Expect(observed).To(BeNumerically("<=", 1),
112+
"expected at most 1 goroutine running the leader function at a time")
113+
})
114+
})
115+
})

core/services/agents/dispatcher.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"sync/atomic"
1111
"time"
1212

13+
"github.com/mudler/LocalAI/core/services/messaging"
14+
1315
coreTypes "github.com/mudler/LocalAGI/core/types"
1416
"github.com/mudler/cogito"
1517
"github.com/mudler/xlog"
@@ -189,15 +191,13 @@ func (d *LocalDispatcher) buildLocalCallbacks(writer SSEWriter, messageID string
189191

190192
// --- NATS Dispatcher (distributed) ---
191193

192-
// NATSSub is a subscription that can be unsubscribed.
193-
type NATSSub interface {
194-
Unsubscribe() error
195-
}
196-
197194
// NATSClient combines publishing and subscribing for NATS.
195+
// It uses messaging.Subscription so that the concrete *messaging.Client
196+
// (whose subscribe methods return *nats.Subscription) satisfies it via
197+
// a thin adapter that widens the return type.
198198
type NATSClient interface {
199199
Publish(subject string, data any) error
200-
QueueSubscribe(subject, queue string, handler func(data []byte)) (NATSSub, error)
200+
QueueSubscribe(subject, queue string, handler func(data []byte)) (messaging.Subscription, error)
201201
}
202202

203203
// NATSDispatcher dispatches agent chats via NATS queue group.
@@ -209,7 +209,7 @@ type NATSDispatcher struct {
209209
apiKey string
210210
subject string
211211
queue string
212-
sub NATSSub // stored subscription for cleanup
212+
sub messaging.Subscription // stored subscription for cleanup
213213
}
214214

215215
// NewNATSDispatcher creates a dispatcher that uses NATS for distribution.

0 commit comments

Comments
 (0)