fix(sync): fix SSE live updates — 2 bugs found during E2E testing

Bug 1: NotifyUser() early-returned when no WebSocket clients existed,
skipping SSE subscriber notifications entirely. Fixed by restructuring
to check WS clients and SSE subscribers independently.

Bug 2: SSE stream cursor defaulted to client's `since` parameter when
no initial data existed. If `since` was in the future (or very recent),
live updates had created_at < cursor and were silently filtered out.
Fixed by defaulting cursor to now() when no initial data is returned.

Bug 3: NotifyUser used original sseSubs slice instead of sseSubsCopy
after releasing the read lock (race condition).

Verified E2E: Push from client A → SSE stream on client B receives
live change event with correct data within ~1 second.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 23:39:46 +02:00
parent 4cb1bda852
commit fed38efb8b
2 changed files with 41 additions and 38 deletions

View file

@ -164,7 +164,7 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
SyncedUntil: now,
}
// Notify other connected clients via WebSocket
// Notify other connected clients via WebSocket/SSE
if len(affectedTables) > 0 {
tables := make([]string, 0, len(affectedTables))
for t := range affectedTables {
@ -326,7 +326,8 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) {
}
}
// Track cursors per collection
// Track cursors per collection — default to now() if no initial data
now := time.Now().UTC().Format(time.RFC3339Nano)
cursors := make(map[string]string)
for _, coll := range collections {
cursors[coll] = since
@ -337,6 +338,7 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) {
changes, err := h.store.GetChangesSince(ctx, userID, appID, coll, since, clientID, batchLimit+1)
if err != nil {
slog.Error("SSE initial pull failed", "error", err, "collection", coll)
cursors[coll] = now // Default to now on error
continue
}
@ -350,6 +352,9 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) {
cursors[coll] = cursor
sendChangeEvent(w, coll, h.convertChanges(changes), cursor, hasMore)
flusher.Flush()
} else {
// No initial data — set cursor to now so live updates work
cursors[coll] = now
}
}

View file

@ -75,38 +75,36 @@ func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request, appID stri
// For legacy per-app connections, only clients matching the appId are notified.
func (h *Hub) NotifyUser(userID, appID, excludeClientID string, tables []string) {
h.mu.RLock()
clients, ok := h.clients[userID]
if !ok {
h.mu.RUnlock()
return
}
clients := h.clients[userID]
sseSubs := h.sseSubscribers[userID]
// Copy the client set under read lock to avoid holding lock during writes
clientsCopy := make([]*Client, 0, len(clients))
// Copy WS clients under read lock
var clientsCopy []*Client
for client := range clients {
// Unified clients (AppID=="") receive all notifications.
// Legacy per-app clients only receive notifications for their app.
if client.AppID == "" || client.AppID == appID {
clientsCopy = append(clientsCopy, client)
}
}
// Copy SSE subscribers
sseSubsCopy := make([]chan Notification, len(sseSubs))
copy(sseSubsCopy, sseSubs)
h.mu.RUnlock()
if len(clientsCopy) == 0 {
// Nothing to notify
if len(clientsCopy) == 0 && len(sseSubsCopy) == 0 {
return
}
// Notify WebSocket clients
if len(clientsCopy) > 0 {
msg := Message{
Type: "sync-available",
AppID: appID,
Tables: tables,
}
data, err := json.Marshal(msg)
if err != nil {
slog.Error("failed to marshal notification", "error", err)
return
}
if err == nil {
for _, client := range clientsCopy {
go func(c *Client) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -116,19 +114,18 @@ func (h *Hub) NotifyUser(userID, appID, excludeClientID string, tables []string)
}
}(client)
}
}
}
// Also notify SSE subscribers
h.mu.RLock()
sseSubs := h.sseSubscribers[userID]
h.mu.RUnlock()
if len(sseSubs) > 0 {
// Notify SSE subscribers
if len(sseSubsCopy) > 0 {
notification := Notification{AppID: appID, Tables: tables}
for _, ch := range sseSubs {
for _, ch := range sseSubsCopy {
select {
case ch <- notification:
// sent
default:
// Drop if channel full (subscriber is slow)
slog.Warn("SSE notification dropped (channel full)", "appID", appID)
}
}
}
@ -286,6 +283,7 @@ func (h *Hub) Subscribe(userID string) chan Notification {
h.sseSubscribers = make(map[string][]chan Notification)
}
h.sseSubscribers[userID] = append(h.sseSubscribers[userID], ch)
slog.Debug("SSE subscribed", "userID", userID, "totalSubscribers", len(h.sseSubscribers[userID]))
return ch
}