ws

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 25, 2026 License: AGPL-3.0 Imports: 7 Imported by: 0

Documentation

Overview

Package ws defines the typed WebSocket message envelope shared by the server upgrade handler (internal/api) and the fanout layer.

Every message exchanged over the /api/v1/ws connection — in both directions — is encoded as a JSON Envelope. Clients send subscription control messages; the server sends push notifications and acknowledgements.

Wire format

All messages are JSON text frames. Binary frames are not used. The top-level structure is always an Envelope:

{
  "type":    "subscribe",
  "subject": "jobs/abc123/tasks",
  "payload": { … },
  "seq":     42
}

Sequence numbers

  • Server TypePush messages carry a per-subject monotonically increasing sequence number assigned by the Hub. The sequence is global across connections so clients can store the last received Seq and pass it as since_seq when reconnecting to replay missed events.
  • Client messages carry a seq chosen by the client. The server echoes it in the corresponding TypeAck response so the client can correlate replies.
  • seq is 0 for messages where sequence tracking is not meaningful (e.g. TypePing / TypePong, TypeAck, TypeError).

Index

Constants

View Source
const (
	// SubjectJobs streams aggregate job-summary events for all jobs.
	SubjectJobs = "jobs"

	// SubjectJobTasksFmt streams task-level events for a specific job.
	// Format: "jobs/{job_id}/tasks".
	SubjectJobTasksFmt = "jobs/%s/tasks"

	// SubjectWorkers streams worker status events.
	SubjectWorkers = "workers"

	// SubjectTaskLogsFmt streams log chunks for a specific task attempt.
	// Format: "tasks/{task_id}/logs".
	SubjectTaskLogsFmt = "tasks/%s/logs"

	// SubjectDiagnostics streams diagnostic (operational) log records from the
	// server and all workers.  Payload is a [DiagnosticsPush]; subscribers
	// filter by component client-side.
	SubjectDiagnostics = "diagnostics"
)

Subject constants for the subscription system. These are prefix patterns; callers substitute resource IDs as needed.

View Source
const JobStatusRemoved = "removed"

JobStatusRemoved is a synthetic JobEvent.Status value (not a persisted [store.JobStatus]) signaling that a job row was hard-deleted — by a manual delete or the job-retention sweep — so subscribers should drop it from their views rather than update it in place.

View Source
const WorkerStatusRemoved = "removed"

WorkerStatusRemoved is a synthetic WorkerEvent.Status value (not a persisted [store.WorkerStatus]) signaling that a worker row was hard-deleted — by a manual remove or the offline-retention sweep — so subscribers should drop it from their views rather than update it in place.

Variables

This section is empty.

Functions

This section is empty.

Types

type AckPayload

type AckPayload struct {
	// ClientSeq echoes the seq from the client message being acknowledged.
	ClientSeq uint64 `json:"client_seq"`

	// Error is non-empty when the acknowledged request failed.
	// Clients should treat a non-empty Error as equivalent to [TypeError] for
	// the specific operation.
	Error string `json:"error,omitempty"`
}

AckPayload is the payload of a TypeAck server message.

type DiagEvent

type DiagEvent struct {
	Component string // "server" or "worker:<id>"
	Level     string // DEBUG|INFO|WARN|ERROR
	Msg       string
	Attrs     map[string]string
	At        time.Time
}

DiagEvent is published when a diagnostic log record is ingested (from the server's own logger or a worker's worker.diag stream). The Hub fans this out to clients subscribed to SubjectDiagnostics.

type DiagnosticsPush

type DiagnosticsPush struct {
	Component string            `json:"component"`
	Level     string            `json:"level"`
	Msg       string            `json:"msg"`
	Attrs     map[string]string `json:"attrs,omitempty"`
	At        time.Time         `json:"at"`
}

DiagnosticsPush is the TypePush payload for SubjectDiagnostics subscriptions.

type Envelope

type Envelope struct {
	// Type is the message discriminator. Required.
	Type MessageType `json:"type"`

	// Subject identifies the resource or stream the message concerns.
	//
	// For client subscribe/unsubscribe: the subscription target
	// (e.g. "jobs/abc123/tasks").
	//
	// For server push: the subject that generated the event.
	//
	// Omitted for ping/pong, ack, and error messages not tied to a subject.
	Subject string `json:"subject,omitempty"`

	// Payload carries message-specific data as a raw JSON value.
	//
	//   TypeSubscribe   → [SubscribePayload]
	//   TypePush        → subject-specific event struct
	//   TypeAck         → [AckPayload]
	//   TypeError       → [ErrorPayload]
	//   TypeUnsubscribe, TypePing, TypePong → nil
	Payload json.RawMessage `json:"payload,omitempty"`

	// Seq is the monotonically increasing sequence number.
	//
	//   TypePush (server → client): a per-subject hub-level counter assigned by
	//   [Hub]. Starts at 1 and increases globally (not per-connection) so
	//   clients can pass the last received Seq as since_seq when reconnecting.
	//
	//   TypeAck (server → client): 0; the acknowledged client seq is carried in
	//   [AckPayload.ClientSeq] instead.
	//
	//   Client messages (TypeSubscribe, TypeUnsubscribe): a client-chosen
	//   counter echoed back in the corresponding TypeAck so the client can
	//   correlate responses.
	//
	//   TypePing / TypePong / TypeError: always 0.
	Seq uint64 `json:"seq"`
}

Envelope is the single message type used for all WebSocket communication. Every frame sent or received on /api/v1/ws is a JSON-encoded Envelope.

The zero value is not a valid message; Type must always be set.

type ErrorPayload

type ErrorPayload struct {
	// Code is a machine-readable error identifier, e.g. "invalid_subject".
	Code string `json:"code"`

	// Message is a human-readable description of the error.
	Message string `json:"message"`
}

ErrorPayload is the payload of a TypeError server message.

type Hub

type Hub struct {
	// contains filtered or unexported fields
}

Hub is the WebSocket fan-out hub. It implements Notifier and routes scheduler events to subscribed WebSocket clients.

Create with NewHub; the zero value is not usable.

func NewHub

func NewHub(logger *slog.Logger) *Hub

NewHub creates a Hub ready for use.

func (*Hub) Deregister

func (h *Hub) Deregister(clientID string)

Deregister removes clientID from all subscriptions and closes its send channel so the send goroutine exits. Safe to call multiple times.

func (*Hub) NotifyDiag

func (h *Hub) NotifyDiag(e DiagEvent)

NotifyDiag fans a diagnostic log record to all SubjectDiagnostics subscribers.

func (*Hub) NotifyJob

func (h *Hub) NotifyJob(e JobEvent)

NotifyJob fans a job-status change to all SubjectJobs subscribers.

func (*Hub) NotifyLog

func (h *Hub) NotifyLog(e LogEvent)

NotifyLog fans a log chunk to clients subscribed to "tasks/{taskID}/logs". The envelope is always stored in the ring buffer so late subscribers can replay it via since_seq: 0 even when no clients are active at emit time.

func (*Hub) NotifyTask

func (h *Hub) NotifyTask(e TaskEvent)

NotifyTask fans a task-status change to:

  • SubjectJobs subscribers (job-summary view of the event)
  • "jobs/{jobID}/tasks" subscribers (per-job task detail)

func (*Hub) NotifyWorker

func (h *Hub) NotifyWorker(e WorkerEvent)

NotifyWorker fans a worker-status change to all SubjectWorkers subscribers.

func (*Hub) Register

func (h *Hub) Register(clientID string) chan Envelope

Register creates a hubClient for clientID and returns its send channel. The caller must start a goroutine that drains the channel and writes frames to the WebSocket connection. Call Hub.Deregister when the connection closes.

Calling Register for an already-registered clientID returns the existing send channel without creating a duplicate (idempotent).

func (*Hub) Subscribe

func (h *Hub) Subscribe(clientID, subject string, sinceSeq uint64) error

Subscribe registers clientID for push notifications on subject. If sinceSeq > 0, buffered ring entries with Seq > sinceSeq are replayed into the client's send channel immediately (best-effort; entries may be dropped if the buffer is full).

Returns an error if subject is not a recognized subscription subject or if clientID is not currently registered.

func (*Hub) Unsubscribe

func (h *Hub) Unsubscribe(clientID, subject string)

Unsubscribe removes clientID from subject's subscription set. No-op when clientID is not subscribed to subject.

type JobEvent

type JobEvent struct {
	JobID     string
	Name      string
	Owner     string
	QueueID   string
	Status    string // store.JobStatus value
	UpdatedAt time.Time
}

JobEvent is published when a job transitions to a new aggregate status (running, completed, failed, canceled). The Hub fans this out to clients subscribed to SubjectJobs.

type JobSummaryPush

type JobSummaryPush struct {
	JobID     string    `json:"job_id"`
	TaskID    string    `json:"task_id,omitempty"`
	Name      string    `json:"name,omitempty"`
	Owner     string    `json:"owner,omitempty"`
	QueueID   string    `json:"queue_id,omitempty"`
	Status    string    `json:"status"`
	UpdatedAt time.Time `json:"updated_at"`
}

JobSummaryPush is the TypePush payload for SubjectJobs subscriptions.

The same payload type carries two kinds of event on the jobs subject, distinguished by TaskID:

  • Job-status changes (from NotifyJob) leave TaskID empty; Status is the job status.
  • Task-status changes (from NotifyTask) set TaskID; Status is the task status. List subscribers use TaskID to apply task-count deltas to the parent job row instead of treating it as a job-status change.

type LogEvent

type LogEvent struct {
	TaskID    string
	AttemptID string
	SeqNum    int64
	Stream    string // "stdout" or "stderr"
	Data      string
	At        time.Time
}

LogEvent is published when a log chunk is persisted by the log-ingestion consumer. The Hub fans this out to clients subscribed to "tasks/{taskID}/logs".

type MessageType

type MessageType string

MessageType is the discriminator field in Envelope.

const (

	// TypeSubscribe requests delivery of server-push messages for Subject.
	// Payload must be a [SubscribePayload].
	TypeSubscribe MessageType = "subscribe"

	// TypeUnsubscribe cancels a previously registered subscription for Subject.
	// No payload required.
	TypeUnsubscribe MessageType = "unsubscribe"

	// TypePing is a client-initiated keep-alive.  The server replies with
	// [TypePong].  No payload; seq is 0.
	TypePing MessageType = "ping"

	// TypePush carries a server-initiated event for the subscribed Subject.
	// Payload shape varies by subject (see subject constants).
	TypePush MessageType = "push"

	// TypeAck confirms receipt of a client message. Payload is an [AckPayload]
	// containing the echoed client seq and an optional error detail.
	TypeAck MessageType = "ack"

	// TypeError reports a protocol-level or subscription error to the client.
	// Payload is an [ErrorPayload].
	TypeError MessageType = "error"

	// TypePong is the server reply to [TypePing].  No payload; seq is 0.
	TypePong MessageType = "pong"
)

type NoopNotifier

type NoopNotifier struct{}

NoopNotifier is a Notifier that discards all events. Use it in tests that exercise the scheduler without a running WebSocket hub.

func (NoopNotifier) NotifyDiag

func (NoopNotifier) NotifyDiag(DiagEvent)

NotifyDiag discards the event.

func (NoopNotifier) NotifyJob

func (NoopNotifier) NotifyJob(JobEvent)

NotifyJob discards the event.

func (NoopNotifier) NotifyLog

func (NoopNotifier) NotifyLog(LogEvent)

NotifyLog discards the event.

func (NoopNotifier) NotifyTask

func (NoopNotifier) NotifyTask(TaskEvent)

NotifyTask discards the event.

func (NoopNotifier) NotifyWorker

func (NoopNotifier) NotifyWorker(WorkerEvent)

NotifyWorker discards the event.

type Notifier

type Notifier interface {
	NotifyTask(e TaskEvent)
	NotifyWorker(e WorkerEvent)
	NotifyLog(e LogEvent)
	NotifyJob(e JobEvent)
	NotifyDiag(e DiagEvent)
}

Notifier is implemented by Hub. Packages outside the ws package — primarily the scheduler — call these methods after persisting state changes to push live events to subscribed WebSocket clients.

All methods must be safe for concurrent use and must not block the caller.

type SubscribePayload

type SubscribePayload struct {
	// SinceSeq instructs the server to replay any buffered push messages with
	// seq strictly greater than SinceSeq before streaming live events.
	// A value of 0 (the default) replays all buffered messages for the subject
	// (hub sequences start at 1, so every buffered entry satisfies seq > 0).
	// Pass the last received Seq to resume without replay.
	SinceSeq uint64 `json:"since_seq,omitempty"`
}

SubscribePayload is the payload of a TypeSubscribe client message.

type TaskEvent

type TaskEvent struct {
	JobID     string
	TaskID    string
	Name      string
	Status    string // store.TaskStatus value
	WorkerID  string // empty when unassigned
	UpdatedAt time.Time
}

TaskEvent is published by the scheduler when a task transitions to a new status. The Hub fans this out to:

  • clients subscribed to SubjectJobs (job-summary updates)
  • clients subscribed to "jobs/{jobID}/tasks" (per-job task updates)

type TaskLogPush

type TaskLogPush struct {
	TaskID    string    `json:"task_id"`
	AttemptID string    `json:"attempt_id,omitempty"`
	SeqNum    int64     `json:"seq_num"`
	Stream    string    `json:"stream"`
	Data      string    `json:"data"`
	At        time.Time `json:"at"`
}

TaskLogPush is the TypePush payload for "tasks/{id}/logs" subscriptions.

type TaskUpdatePush

type TaskUpdatePush struct {
	JobID     string    `json:"job_id"`
	TaskID    string    `json:"task_id"`
	Name      string    `json:"name,omitempty"`
	Status    string    `json:"status"`
	WorkerID  string    `json:"worker_id,omitempty"`
	UpdatedAt time.Time `json:"updated_at"`
}

TaskUpdatePush is the TypePush payload for "jobs/{id}/tasks" subscriptions.

type WorkerEvent

type WorkerEvent struct {
	WorkerID string
	Name     string
	Hostname string
	FarmID   string
	Status   string // store.WorkerStatus value
}

WorkerEvent is published when a worker registers, sends a heartbeat, deregisters, has its status changed by the heartbeat sweep, or is removed. The Hub fans this out to clients subscribed to SubjectWorkers.

type WorkerStatusPush

type WorkerStatusPush struct {
	WorkerID string `json:"worker_id"`
	Name     string `json:"name,omitempty"`
	Hostname string `json:"hostname,omitempty"`
	FarmID   string `json:"farm_id,omitempty"`
	Status   string `json:"status"`
}

WorkerStatusPush is the TypePush payload for SubjectWorkers subscriptions.

Its field set and order must mirror WorkerEvent exactly: the hub builds the push via the direct struct conversion WorkerStatusPush(e) (see buildEnvelope call in NotifyWorker). Adding or reordering a field here without doing the same in WorkerEvent breaks that conversion at compile time.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL