Files
geo/server/internal/shared/stream/desktop_dispatch.go
T
root 67be43319e
Desktop Client Build / Resolve Build Metadata (push) Successful in 1m13s
Desktop Client Build / Build Desktop Client (push) Has been cancelled
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled
Backend CI / Backend (push) Failing after 13m49s
Frontend CI / Frontend (push) Successful in 6m38s
feat(desktop&tenant): Enhance desktop account and task consumer presence management
- Added new fields to DesktopAccountInfo for tracking account session and task consumer online status, along with queued publish task count and oldest queued publish task timestamp.
- Updated DesktopDispatchEvent to include server time for better synchronization.
- Implemented separate presence management for task consumers, including marking presence and loading presence state.
- Enhanced DesktopAccountService to apply publish queue summaries and manage task consumer presence.
- Introduced new methods for replaying queued publish tasks in DesktopDispatchHandler.
- Updated tests to cover new presence management logic and ensure correct behavior of account session and task consumer online status.
2026-06-07 19:15:20 +08:00

282 lines
8.3 KiB
Go

package stream
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
amqp "github.com/rabbitmq/amqp091-go"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
)
// DesktopDispatchEvent is the payload pushed to a desktop client over the
// dispatch WebSocket. The shape intentionally mirrors DesktopTaskEvent so that
// the existing SSE handler on the desktop client can treat both channels the
// same way and dedupe by task_id.
type DesktopDispatchEvent struct {
Type string `json:"type"`
TaskID string `json:"task_id"`
JobID string `json:"job_id,omitempty"`
WorkspaceID int64 `json:"workspace_id"`
TargetAccountID string `json:"target_account_id,omitempty"`
TargetClientID string `json:"target_client_id"`
Platform string `json:"platform,omitempty"`
Title *string `json:"title,omitempty"`
BusinessDate *string `json:"business_date,omitempty"`
SchedulerGroupKey *string `json:"scheduler_group_key,omitempty"`
QuestionText *string `json:"question_text,omitempty"`
Status string `json:"status"`
Kind string `json:"kind"`
Priority int `json:"priority,omitempty"`
Lane string `json:"lane,omitempty"`
InterruptGeneration int `json:"interrupt_generation,omitempty"`
SignalOnly bool `json:"signal_only,omitempty"`
Control string `json:"control,omitempty"`
Reason string `json:"reason,omitempty"`
ReplacementTaskID string `json:"replacement_task_id,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
ServerTime *time.Time `json:"server_time,omitempty"`
}
// DispatchSubscriber represents a live WebSocket connection bound to a single
// desktop client id. A client may have multiple subscribers (e.g. during a
// reconnect race), so the hub stores them as a set per client id.
type DispatchSubscriber struct {
id uint64
out chan DesktopDispatchEvent
closed chan struct{}
}
// Outbound returns the channel the handler goroutine should read from to write
// frames to the WebSocket. The channel is closed when the subscriber is
// unregistered; readers should range over it.
func (s *DispatchSubscriber) Outbound() <-chan DesktopDispatchEvent {
return s.out
}
type dispatchClientStream struct {
subscribers map[uint64]*DispatchSubscriber
}
// DesktopDispatchHub delivers per-client dispatch messages pulled from the
// desktop.task.dispatch topic exchange. Every tenant-api instance owns its own
// autodelete queue bound to the exchange, so every instance sees every message
// and routes it to the subscribers it currently owns. Clients not connected to
// this instance are simply filtered out; the client reconnects via the load
// balancer and lands on whichever instance currently holds its WebSocket.
type DesktopDispatchHub struct {
mu sync.RWMutex
clients map[string]*dispatchClientStream
rabbitMQ *rabbitmq.Client
logger *zap.Logger
instanceID string
consumerName string
bindingKeys []string
nextSubID uint64
}
// NewDesktopDispatchHub wires the hub to the shared rabbitmq client. The
// supplied binding keys decide which dispatch routing keys this instance
// receives; initially that is just `publish.*`, and the monitor slice will be
// added when the monitor chain is migrated.
func NewDesktopDispatchHub(rabbitMQClient *rabbitmq.Client, logger *zap.Logger, bindingKeys []string) *DesktopDispatchHub {
instanceID := fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano())
keys := make([]string, 0, len(bindingKeys))
for _, key := range bindingKeys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
keys = append(keys, trimmed)
}
if len(keys) == 0 {
keys = []string{"publish.*"}
}
return &DesktopDispatchHub{
clients: make(map[string]*dispatchClientStream),
rabbitMQ: rabbitMQClient,
logger: logger,
instanceID: instanceID,
consumerName: "desktop-dispatch-" + instanceID,
bindingKeys: keys,
}
}
// Run starts the background AMQP consumer. It is safe to call Run on a hub
// without a rabbitmq client — the hub simply behaves as an in-process broker
// used in tests, useful for unit-level wiring.
func (h *DesktopDispatchHub) Run(ctx context.Context) {
if h == nil || h.rabbitMQ == nil {
return
}
go h.run(ctx)
}
// Subscribe registers a new WebSocket consumer for the given client id. The
// returned cancel closes the outbound channel and removes the subscriber; the
// WebSocket handler must call it on disconnect.
func (h *DesktopDispatchHub) Subscribe(clientID string) (*DispatchSubscriber, func()) {
if h == nil || clientID == "" {
return nil, func() {}
}
h.mu.Lock()
stream, ok := h.clients[clientID]
if !ok {
stream = &dispatchClientStream{subscribers: make(map[uint64]*DispatchSubscriber)}
h.clients[clientID] = stream
}
h.nextSubID++
sub := &DispatchSubscriber{
id: h.nextSubID,
out: make(chan DesktopDispatchEvent, 32),
closed: make(chan struct{}),
}
stream.subscribers[sub.id] = sub
h.mu.Unlock()
cancel := func() {
h.mu.Lock()
stream, ok := h.clients[clientID]
if !ok {
h.mu.Unlock()
return
}
existing, present := stream.subscribers[sub.id]
if !present {
h.mu.Unlock()
return
}
delete(stream.subscribers, sub.id)
if len(stream.subscribers) == 0 {
delete(h.clients, clientID)
}
h.mu.Unlock()
select {
case <-existing.closed:
// already closed
default:
close(existing.closed)
close(existing.out)
}
}
return sub, cancel
}
// Publish delivers the event to every subscriber currently registered for the
// event's target client id on this instance. Events for clients that are not
// on this instance are dropped silently, by design.
func (h *DesktopDispatchHub) Publish(event DesktopDispatchEvent) {
if h == nil || event.TargetClientID == "" {
return
}
h.mu.RLock()
stream, ok := h.clients[event.TargetClientID]
if !ok {
h.mu.RUnlock()
return
}
subs := make([]*DispatchSubscriber, 0, len(stream.subscribers))
for _, sub := range stream.subscribers {
subs = append(subs, sub)
}
h.mu.RUnlock()
for _, sub := range subs {
select {
case <-sub.closed:
continue
case sub.out <- event:
default:
// Subscriber's outbound channel is full: the reader has fallen
// behind. We drop the message rather than block the dispatch
// consumer — the client will resync via the lease API on
// reconnect.
if h.logger != nil {
h.logger.Warn("desktop dispatch subscriber slow, dropping event",
zap.String("client_id", event.TargetClientID),
zap.String("task_id", event.TaskID),
)
}
}
}
}
// ActiveClientIDs returns the set of client ids currently connected on this
// instance. Intended for diagnostics and the /api/health/ready endpoint.
func (h *DesktopDispatchHub) ActiveClientIDs() []string {
if h == nil {
return nil
}
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]string, 0, len(h.clients))
for id := range h.clients {
out = append(out, id)
}
return out
}
func (h *DesktopDispatchHub) run(ctx context.Context) {
for {
deliveries, ch, err := h.rabbitMQ.ConsumeDesktopDispatch(h.consumerName, h.bindingKeys)
if err != nil {
if h.logger != nil {
h.logger.Warn("desktop dispatch consume failed, retrying",
zap.Error(err),
)
}
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
continue
}
}
closed := false
for !closed {
select {
case <-ctx.Done():
_ = ch.Close()
return
case delivery, ok := <-deliveries:
if !ok {
closed = true
continue
}
h.handleDelivery(delivery)
}
}
_ = ch.Close()
}
}
func (h *DesktopDispatchHub) handleDelivery(delivery amqp.Delivery) {
var event DesktopDispatchEvent
if err := json.Unmarshal(delivery.Body, &event); err != nil {
if h.logger != nil {
h.logger.Warn("desktop dispatch event decode failed",
zap.Error(err),
zap.String("routing_key", delivery.RoutingKey),
)
}
return
}
if event.TargetClientID == "" {
// Fall back to the routing key: `{prefix}.{client_id}` -> client_id.
if idx := strings.LastIndex(delivery.RoutingKey, "."); idx >= 0 && idx < len(delivery.RoutingKey)-1 {
event.TargetClientID = delivery.RoutingKey[idx+1:]
}
}
h.Publish(event)
}