Files
LocalAI/core/services/jobs/sse.go
Ettore Di Giacinto 59108fbe32 feat: add distributed mode (#9124)
* feat: add distributed mode (experimental)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix data races, mutexes, transactions

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix events and tool stream in agent chat

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* use ginkgo

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(cron): compute correctly time boundaries avoiding re-triggering

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* enhancements, refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* do not flood of healthy checks

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* do not list obvious backends as text backends

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* tests fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Drop redundant healthcheck

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* enhancements, refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-30 00:47:27 +02:00

98 lines
2.7 KiB
Go

package jobs
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"sync/atomic"
"github.com/labstack/echo/v4"
)
// SSEBridge provides an HTTP handler that bridges NATS progress events to SSE.
// This follows the notetaker pattern: subscribe to NATS, forward to SSE client.
func (d *Dispatcher) SSEHandler() echo.HandlerFunc {
return func(c echo.Context) error {
jobID := c.Param("id")
if jobID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "job ID required"})
}
// Check flusher support before writing any headers
flusher, ok := c.Response().Writer.(http.Flusher)
if !ok {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "streaming not supported"})
}
// Set SSE headers
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
c.Response().WriteHeader(http.StatusOK)
// Thread-safe event writer with close guard to prevent writes after handler returns
var mu sync.Mutex
var closed atomic.Bool
sendEvent := func(event string, data any) {
if closed.Load() {
return
}
jsonData, err := json.Marshal(data)
if err != nil {
return
}
mu.Lock()
defer mu.Unlock()
fmt.Fprintf(c.Response(), "event: %s\ndata: %s\n\n", event, jsonData)
flusher.Flush()
}
// Send current job state first
job, err := d.store.GetJob(jobID)
if err == nil {
sendEvent("status", ProgressEvent{
JobID: jobID,
Status: job.Status,
})
// If already terminal, send done and close — no need to subscribe
if job.Status == "completed" || job.Status == "failed" || job.Status == "cancelled" {
sendEvent("done", ProgressEvent{
JobID: jobID,
Status: job.Status,
})
return nil
}
}
// done is closed when a terminal state event is received, so the
// handler can return promptly instead of waiting for client disconnect.
done := make(chan struct{})
closeOnce := sync.Once{}
// Subscribe to progress events for this job
sub, err := d.SubscribeProgress(jobID, func(evt ProgressEvent) {
sendEvent("progress", evt)
// Close the stream on terminal states
if evt.Status == "completed" || evt.Status == "failed" || evt.Status == "cancelled" {
sendEvent("done", evt)
closeOnce.Do(func() { close(done) })
}
})
if err != nil {
// Headers already written as SSE — cannot send JSON error; use SSE event instead
sendEvent("error", map[string]string{"error": "failed to subscribe"})
return nil
}
// Wait for client disconnect or terminal state
select {
case <-c.Request().Context().Done():
case <-done:
}
closed.Store(true)
sub.Unsubscribe()
return nil
}
}