Skip to content

Commit e2961fd

Browse files
authored
feat(llmobs): add internal FlushSync function (#4695)
1 parent ffc33dc commit e2961fd

2 files changed

Lines changed: 133 additions & 18 deletions

File tree

internal/llmobs/llmobs.go

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,10 @@ type LLMObs struct {
146146
// lifecycle
147147
mu sync.Mutex
148148
running bool
149-
wg sync.WaitGroup
150-
stopCh chan struct{} // signal stop
151-
flushNowCh chan struct{}
149+
sendWg sync.WaitGroup // tracks in-flight batchSend goroutines
150+
workerDone chan struct{} // closed when the worker loop exits
151+
stopCh chan struct{} // signal stop
152+
flushNowCh chan chan struct{}
152153
flushInterval time.Duration
153154
}
154155

@@ -187,8 +188,9 @@ func newLLMObs(cfg *config.Config, tracer Tracer) (*LLMObs, error) {
187188
Tracer: tracer,
188189
spanEventsCh: make(chan *transport.LLMObsSpanEvent),
189190
evalMetricsCh: make(chan *transport.LLMObsMetric),
191+
workerDone: make(chan struct{}),
190192
stopCh: make(chan struct{}),
191-
flushNowCh: make(chan struct{}, 1),
193+
flushNowCh: make(chan chan struct{}, 1),
192194
flushInterval: defaultFlushInterval,
193195
}, nil
194196
}
@@ -245,6 +247,13 @@ func Flush() {
245247
}
246248
}
247249

250+
// FlushSync forces a flush of all buffered LLMObs data and blocks until the flush completes.
251+
func FlushSync() {
252+
if activeLLMObs != nil {
253+
activeLLMObs.FlushSync()
254+
}
255+
}
256+
248257
// Run starts the worker loop that processes span events and metrics.
249258
func (l *LLMObs) Run() {
250259
l.mu.Lock()
@@ -255,7 +264,8 @@ func (l *LLMObs) Run() {
255264
l.running = true
256265
l.mu.Unlock()
257266

258-
l.wg.Go(func() {
267+
go func() {
268+
defer close(l.workerDone)
259269
// this goroutine should be the only one writing to the internal buffers
260270

261271
ticker := time.NewTicker(l.flushInterval)
@@ -268,9 +278,7 @@ func (l *LLMObs) Run() {
268278
if l.bufSpanEventsSize+evSize > sizeLimitEVPEvent {
269279
log.Debug("llmobs: span events buffer size limit reached, flushing before adding new event")
270280
params := l.clearBuffersNonLocked()
271-
l.wg.Go(func() {
272-
l.batchSend(params)
273-
})
281+
l.sendWg.Go(func() { l.batchSend(params) })
274282
}
275283
l.bufSpanEvents = append(l.bufSpanEvents, ev)
276284
l.bufSpanEventsSize += evSize
@@ -280,16 +288,22 @@ func (l *LLMObs) Run() {
280288

281289
case <-ticker.C:
282290
params := l.clearBuffersNonLocked()
283-
l.wg.Go(func() {
284-
l.batchSend(params)
285-
})
291+
l.sendWg.Go(func() { l.batchSend(params) })
286292

287-
case <-l.flushNowCh:
293+
case done := <-l.flushNowCh:
288294
log.Debug("llmobs: on-demand flush signal")
289295
params := l.clearBuffersNonLocked()
290-
l.wg.Go(func() {
296+
l.sendWg.Add(1)
297+
go func() {
298+
defer func() {
299+
l.sendWg.Done()
300+
if done != nil {
301+
l.sendWg.Wait()
302+
close(done)
303+
}
304+
}()
291305
l.batchSend(params)
292-
})
306+
}()
293307

294308
case <-l.stopCh:
295309
log.Debug("llmobs: stop signal")
@@ -299,7 +313,7 @@ func (l *LLMObs) Run() {
299313
return
300314
}
301315
}
302-
})
316+
}()
303317
}
304318

305319
// clearBuffersNonLocked clears the internal buffers and returns the corresponding batchSendParams to send to the backend.
@@ -320,11 +334,24 @@ func (l *LLMObs) clearBuffersNonLocked() batchSendParams {
320334
func (l *LLMObs) Flush() {
321335
// non-blocking edge trigger so multiple calls coalesce
322336
select {
323-
case l.flushNowCh <- struct{}{}:
337+
case l.flushNowCh <- nil:
324338
default:
325339
}
326340
}
327341

342+
// FlushSync forces an immediate flush and blocks until the flush completes.
343+
func (l *LLMObs) FlushSync() {
344+
done := make(chan struct{})
345+
select {
346+
case l.flushNowCh <- done:
347+
select {
348+
case <-done:
349+
case <-l.stopCh:
350+
}
351+
case <-l.stopCh:
352+
}
353+
}
354+
328355
// Stop requests shutdown, drains what’s already in the channels, flushes, and waits.
329356
func (l *LLMObs) Stop() {
330357
l.mu.Lock()
@@ -342,8 +369,10 @@ func (l *LLMObs) Stop() {
342369
close(l.stopCh)
343370
}
344371

345-
// Wait for the main worker to exit (it will do a final flush)
346-
l.wg.Wait()
372+
// Wait for the worker loop to exit (it does a final synchronous flush),
373+
// then wait for any async batchSend goroutines still in flight.
374+
<-l.workerDone
375+
l.sendWg.Wait()
347376
}
348377

349378
// drainChannels pulls everything currently buffered in the channels into our in-memory buffers.

internal/llmobs/llmobs_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2335,3 +2335,89 @@ func TestSpanEventsSizeBasedFlushing(t *testing.T) {
23352335
"all spans accumulate in a single batch that is too large to send", size)
23362336
}
23372337
}
2338+
2339+
func TestFlushSync(t *testing.T) {
2340+
t.Run("does-not-hang-with-empty-buffer", func(t *testing.T) {
2341+
// FlushSync must return promptly even when there is nothing to flush.
2342+
_, ll := testTracer(t)
2343+
2344+
done := make(chan struct{})
2345+
go func() {
2346+
ll.FlushSync()
2347+
close(done)
2348+
}()
2349+
select {
2350+
case <-done:
2351+
case <-time.After(5 * time.Second):
2352+
t.Fatal("FlushSync hung with empty buffer")
2353+
}
2354+
})
2355+
t.Run("waits-for-slow-transport", func(t *testing.T) {
2356+
// FlushSync must block the caller until the HTTP send completes.
2357+
// Verify by timing: if FlushSync returned before the request delay,
2358+
// the blocking guarantee would be violated.
2359+
const delay = 200 * time.Millisecond
2360+
2361+
tt, ll := testTracer(t, testtracer.WithRequestDelay(delay))
2362+
2363+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "slow-span", llmobs.StartSpanConfig{})
2364+
span.Finish(llmobs.FinishSpanConfig{})
2365+
2366+
start := time.Now()
2367+
ll.FlushSync()
2368+
elapsed := time.Since(start)
2369+
2370+
// Must have waited at least the request delay.
2371+
assert.GreaterOrEqual(t, elapsed, delay, "FlushSync should block until the slow HTTP send completes")
2372+
2373+
spans := tt.WaitForLLMObsSpans(t, 1)
2374+
require.Len(t, spans, 1)
2375+
assert.Equal(t, "slow-span", spans[0].Name)
2376+
})
2377+
t.Run("no-panic-without-active-llmobs", func(t *testing.T) {
2378+
// Package-level FlushSync should be safe when no active LLMObs.
2379+
assert.NotPanics(t, func() {
2380+
llmobs.FlushSync()
2381+
})
2382+
})
2383+
t.Run("waits-for-data-queued-by-flush", func(t *testing.T) {
2384+
// Flush() triggers batchSend asynchronously. If FlushSync() is called
2385+
// right after, it should still block until that batchSend completes.
2386+
// Without a fix, FlushSync gets an already-empty buffer (Flush cleared
2387+
// it) and returns immediately while the in-flight batchSend is still running.
2388+
const delay = 200 * time.Millisecond
2389+
_, ll := testTracer(t, testtracer.WithRequestDelay(delay))
2390+
2391+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "test-span", llmobs.StartSpanConfig{})
2392+
span.Finish(llmobs.FinishSpanConfig{})
2393+
2394+
ll.Flush()
2395+
start := time.Now()
2396+
ll.FlushSync()
2397+
elapsed := time.Since(start)
2398+
2399+
assert.GreaterOrEqual(t, elapsed, delay-20*time.Millisecond,
2400+
"FlushSync should block until the batchSend triggered by the preceding Flush completes")
2401+
})
2402+
t.Run("does-not-hang-after-stop", func(t *testing.T) {
2403+
_, ll := testTracer(t)
2404+
ll.Stop()
2405+
2406+
// After Stop, stopCh is closed and the worker is gone. FlushSync's select
2407+
// has two ready cases: <-stopCh and flushNowCh<-done (buffered, empty).
2408+
// Go picks randomly, so loop to reliably hit the flushNowCh branch —
2409+
// without the fix that branch blocks forever on <-done.
2410+
done := make(chan struct{})
2411+
go func() {
2412+
defer close(done)
2413+
for range 20 {
2414+
ll.FlushSync()
2415+
}
2416+
}()
2417+
select {
2418+
case <-done:
2419+
case <-time.After(5 * time.Second):
2420+
t.Fatal("FlushSync hung after Stop")
2421+
}
2422+
})
2423+
}

0 commit comments

Comments
 (0)