mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-09-22 17:44:59 +02:00
Fixes four reported issues. #321 / #322 — POST /osm/api/runs was unusable when the server was started with --settings-file. That branch in pkg/cli/root.go loaded the settings via config.LoadFromFile (a plain YAML unmarshal) and never called ResolvePaths(), so every derived runtime path stayed empty. An empty WorkflowsPath made workflow lookups scan "" (reported as "Workflow not found") and made a flow's relative module refs resolve against the process working directory. - root.go now resolves paths (and applies env overrides) on that branch - ResolvePaths backfills any environments.* key a partial settings file omits, sourced from the new defaultEnvironments() so DefaultConfig and the backfill can no longer drift - parser.ErrWorkflowsDirNotConfigured replaces the silent cwd fallback, so all loader call sites report the misconfiguration instead of a misleading 404 #323 — a run submitted with run_mode:distributed could be lost permanently. BRPOP is at-most-once: a task popped but not yet recorded in osm:tasks:running existed nowhere, and the master's recovery sweep only reads that hash. - workers now claim via BLMOVE onto osm:tasks:processing:{worker_id} - the claim is acked only once SetTaskRunning succeeds; a failure requeues instead of executing the task untracked - recovery on worker startup, on dead workers, and for processing lists left by workers that are no longer registered - requires Redis 6.2+ #320 — settings values can now be overridden by OSM_* environment variables so secrets need not live in osm-settings.yaml. The mapping is derived from the YAML tags by reflection, so new settings are overridable with no extra code. Applied in config.Load, hotreload and the --settings-file branch, but deliberately not in LoadFromFile: `osmedeus config set` round-trips through it and writes back, which would persist env secrets to disk. Known gaps: pkg/cli/worker_queue.go still consumes the pending queue with the at-most-once PopTask, and two distributed e2e tests (TaskSubmission, FullWorkflow) fail on main independently of these changes.
655 lines
20 KiB
Go
655 lines
20 KiB
Go
package distributed
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/j3ssie/osmedeus/v5/internal/config"
|
|
"github.com/j3ssie/osmedeus/v5/internal/core"
|
|
"github.com/redis/rueidis"
|
|
)
|
|
|
|
// Redis key prefixes
|
|
const (
|
|
KeyPrefix = "osm:"
|
|
KeyTasksPending = KeyPrefix + "tasks:pending"
|
|
KeyTasksRunning = KeyPrefix + "tasks:running"
|
|
KeyTasksCompleted = KeyPrefix + "tasks:completed"
|
|
KeyTasksProcessing = KeyPrefix + "tasks:processing:" // osm:tasks:processing:{worker_id}
|
|
KeyWorkers = KeyPrefix + "workers"
|
|
KeyWorkersHeartbeat = KeyPrefix + "workers:heartbeat"
|
|
KeyMasterLock = KeyPrefix + "master:lock"
|
|
|
|
// Event broker keys (pub/sub channels)
|
|
KeyEventsPrefix = KeyPrefix + "events:" // osm:events:{topic}
|
|
|
|
// Data queue keys (for worker -> master data)
|
|
KeyDataRuns = KeyPrefix + "data:runs"
|
|
KeyDataSteps = KeyPrefix + "data:steps"
|
|
KeyDataEvents = KeyPrefix + "data:events"
|
|
KeyDataArtifacts = KeyPrefix + "data:artifacts"
|
|
KeyDataExecute = KeyPrefix + "data:execute"
|
|
KeyDataExecuteWorker = KeyPrefix + "data:execute:worker:" // osm:data:execute:worker:{worker_id}
|
|
)
|
|
|
|
// KeyTasksProcessingForWorker returns the per-worker in-flight task list key.
|
|
// A task lives here between being claimed off the pending queue and being
|
|
// recorded in KeyTasksRunning, so a worker that dies in that window does not
|
|
// take the task with it.
|
|
func KeyTasksProcessingForWorker(workerID string) string {
|
|
return KeyTasksProcessing + workerID
|
|
}
|
|
|
|
// KeyDataExecuteForWorker returns the per-worker execute queue key.
|
|
func KeyDataExecuteForWorker(workerID string) string {
|
|
return KeyDataExecuteWorker + workerID
|
|
}
|
|
|
|
// Timeouts and intervals
|
|
const (
|
|
HeartbeatInterval = 30 * time.Second
|
|
HeartbeatTimeout = 90 * time.Second // 3 missed heartbeats
|
|
TaskPollTimeout = 5 * time.Second
|
|
DefaultConnectTimeout = 60 * time.Second
|
|
)
|
|
|
|
// Client wraps a rueidis client with helper methods
|
|
type Client struct {
|
|
client rueidis.Client
|
|
cfg *config.RedisConfig
|
|
}
|
|
|
|
// NewClient creates a new Redis client from configuration
|
|
func NewClient(cfg *config.RedisConfig) (*Client, error) {
|
|
if cfg.Host == "" {
|
|
return nil, fmt.Errorf("redis host not configured")
|
|
}
|
|
|
|
port := cfg.Port
|
|
if port == 0 {
|
|
port = 6379
|
|
}
|
|
|
|
opts := rueidis.ClientOption{
|
|
InitAddress: []string{fmt.Sprintf("%s:%d", cfg.Host, port)},
|
|
Username: cfg.Username,
|
|
Password: cfg.Password,
|
|
SelectDB: cfg.DB,
|
|
DisableCache: true, // Disable client-side caching for simpler behavior
|
|
}
|
|
|
|
client, err := rueidis.NewClient(opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create redis client: %w", err)
|
|
}
|
|
|
|
return &Client{
|
|
client: client,
|
|
cfg: cfg,
|
|
}, nil
|
|
}
|
|
|
|
// NewClientFromConfig creates a client from the global config
|
|
func NewClientFromConfig(cfg *config.Config) (*Client, error) {
|
|
return NewClient(&cfg.Redis)
|
|
}
|
|
|
|
// ParseRedisURL parses a Redis connection URL into RedisConfig
|
|
// Format: redis://[username:password@]host:port[/db]
|
|
func ParseRedisURL(redisURL string) (*config.RedisConfig, error) {
|
|
if !strings.HasPrefix(redisURL, "redis://") {
|
|
redisURL = "redis://" + redisURL
|
|
}
|
|
|
|
u, err := url.Parse(redisURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid redis URL: %w", err)
|
|
}
|
|
|
|
cfg := &config.RedisConfig{
|
|
Host: u.Hostname(),
|
|
Port: 6379,
|
|
ConnectionTimeout: 60,
|
|
}
|
|
|
|
if u.Port() != "" {
|
|
port, err := strconv.Atoi(u.Port())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid redis port: %w", err)
|
|
}
|
|
cfg.Port = port
|
|
}
|
|
|
|
if u.User != nil {
|
|
cfg.Username = u.User.Username()
|
|
cfg.Password, _ = u.User.Password()
|
|
}
|
|
|
|
if u.Path != "" && u.Path != "/" {
|
|
db, err := strconv.Atoi(strings.TrimPrefix(u.Path, "/"))
|
|
if err == nil {
|
|
cfg.DB = db
|
|
}
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// Close closes the Redis client
|
|
func (c *Client) Close() {
|
|
c.client.Close()
|
|
}
|
|
|
|
// Ping tests the Redis connection
|
|
func (c *Client) Ping(ctx context.Context) error {
|
|
cmd := c.client.B().Ping().Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// Raw returns the underlying rueidis client
|
|
func (c *Client) Raw() rueidis.Client {
|
|
return c.client
|
|
}
|
|
|
|
// PushTask pushes a task to the pending queue
|
|
func (c *Client) PushTask(ctx context.Context, task *Task) error {
|
|
data, err := task.MarshalJSON()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal task: %w", err)
|
|
}
|
|
|
|
cmd := c.client.B().Lpush().Key(KeyTasksPending).Element(string(data)).Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// PopTask pops a task from the pending queue (blocking).
|
|
//
|
|
// Deprecated: BRPOP is at-most-once -- if the caller dies, is canceled, or loses
|
|
// the connection between Redis popping the element and the caller recording it,
|
|
// the task is gone for good. Use ClaimTask, which moves the task to a per-worker
|
|
// processing list in the same atomic step. Retained for the task-queue poller in
|
|
// pkg/cli/worker_queue.go, which re-queues from the database instead.
|
|
func (c *Client) PopTask(ctx context.Context, timeout time.Duration) (*Task, error) {
|
|
cmd := c.client.B().Brpop().Key(KeyTasksPending).Timeout(timeout.Seconds()).Build()
|
|
result, err := c.client.Do(ctx, cmd).AsStrSlice()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return nil, nil // Timeout, no task available
|
|
}
|
|
return nil, fmt.Errorf("failed to pop task: %w", err)
|
|
}
|
|
|
|
if len(result) < 2 {
|
|
return nil, nil // No task
|
|
}
|
|
|
|
return UnmarshalTask([]byte(result[1]))
|
|
}
|
|
|
|
// ClaimTask atomically moves a task from the pending queue onto the calling
|
|
// worker's processing list and returns it, along with the raw payload needed to
|
|
// acknowledge it later.
|
|
//
|
|
// The move is a single BLMOVE, so the task is never held only in the worker's
|
|
// memory: if the worker dies before finishing, the task stays on its processing
|
|
// list and RecoverProcessingTasks puts it back. Requires Redis 6.2+.
|
|
func (c *Client) ClaimTask(ctx context.Context, workerID string, timeout time.Duration) (*Task, string, error) {
|
|
// RIGHT off pending keeps FIFO order (PushTask LPUSHes onto the head).
|
|
cmd := c.client.B().Blmove().
|
|
Source(KeyTasksPending).
|
|
Destination(KeyTasksProcessingForWorker(workerID)).
|
|
Right().
|
|
Left().
|
|
Timeout(timeout.Seconds()).
|
|
Build()
|
|
|
|
payload, err := c.client.Do(ctx, cmd).ToString()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return nil, "", nil // Timeout, no task available
|
|
}
|
|
return nil, "", fmt.Errorf("failed to claim task: %w", err)
|
|
}
|
|
|
|
task, err := UnmarshalTask([]byte(payload))
|
|
if err != nil {
|
|
// Payload is unusable; drop it from the processing list so it does not
|
|
// get replayed forever on every recovery sweep.
|
|
_ = c.AckTask(ctx, workerID, payload)
|
|
return nil, "", fmt.Errorf("failed to unmarshal claimed task: %w", err)
|
|
}
|
|
|
|
return task, payload, nil
|
|
}
|
|
|
|
// AckTask removes a claimed task from a worker's processing list, marking it as
|
|
// no longer in flight. Safe to call more than once.
|
|
func (c *Client) AckTask(ctx context.Context, workerID, payload string) error {
|
|
cmd := c.client.B().Lrem().
|
|
Key(KeyTasksProcessingForWorker(workerID)).
|
|
Count(1).
|
|
Element(payload).
|
|
Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// RequeueTask returns a single claimed task to the pending queue. If the LREM
|
|
// succeeds but the push does not, the task stays on the processing list and is
|
|
// picked up by RecoverProcessingTasks, so no path drops it.
|
|
func (c *Client) RequeueTask(ctx context.Context, workerID, payload string) error {
|
|
task, err := UnmarshalTask([]byte(payload))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to unmarshal task for requeue: %w", err)
|
|
}
|
|
if err := c.PushTask(ctx, task); err != nil {
|
|
return err
|
|
}
|
|
return c.AckTask(ctx, workerID, payload)
|
|
}
|
|
|
|
// RecoverProcessingTasks moves every task still on a worker's processing list
|
|
// back onto the pending queue and returns how many were recovered. Used when a
|
|
// worker is found dead, and by a worker itself on startup to reclaim tasks it
|
|
// lost to an earlier crash.
|
|
func (c *Client) RecoverProcessingTasks(ctx context.Context, workerID string) (int, error) {
|
|
key := KeyTasksProcessingForWorker(workerID)
|
|
recovered := 0
|
|
|
|
for {
|
|
// Onto the tail of pending, so a recovered task is taken next rather
|
|
// than queueing behind everything submitted while the worker was down.
|
|
cmd := c.client.B().Lmove().
|
|
Source(key).
|
|
Destination(KeyTasksPending).
|
|
Right().
|
|
Right().
|
|
Build()
|
|
|
|
if err := c.client.Do(ctx, cmd).Error(); err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return recovered, nil // List drained
|
|
}
|
|
return recovered, fmt.Errorf("failed to recover in-flight tasks: %w", err)
|
|
}
|
|
recovered++
|
|
}
|
|
}
|
|
|
|
// ListProcessingWorkerIDs returns the worker IDs that currently have a
|
|
// processing list in Redis, including workers that are no longer registered.
|
|
func (c *Client) ListProcessingWorkerIDs(ctx context.Context) ([]string, error) {
|
|
var ids []string
|
|
cursor := uint64(0)
|
|
|
|
for {
|
|
cmd := c.client.B().Scan().Cursor(cursor).Match(KeyTasksProcessing + "*").Count(100).Build()
|
|
entry, err := c.client.Do(ctx, cmd).AsScanEntry()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan processing lists: %w", err)
|
|
}
|
|
for _, key := range entry.Elements {
|
|
ids = append(ids, strings.TrimPrefix(key, KeyTasksProcessing))
|
|
}
|
|
if entry.Cursor == 0 {
|
|
return ids, nil
|
|
}
|
|
cursor = entry.Cursor
|
|
}
|
|
}
|
|
|
|
// SetTaskRunning moves a task to the running hash
|
|
func (c *Client) SetTaskRunning(ctx context.Context, task *Task) error {
|
|
data, err := task.MarshalJSON()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal task: %w", err)
|
|
}
|
|
|
|
cmd := c.client.B().Hset().Key(KeyTasksRunning).FieldValue().FieldValue(task.ID, string(data)).Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// RemoveTaskRunning removes a task from the running hash
|
|
func (c *Client) RemoveTaskRunning(ctx context.Context, taskID string) error {
|
|
cmd := c.client.B().Hdel().Key(KeyTasksRunning).Field(taskID).Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// SetTaskResult stores a task result in the completed hash
|
|
func (c *Client) SetTaskResult(ctx context.Context, result *TaskResult) error {
|
|
data, err := result.MarshalJSON()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal result: %w", err)
|
|
}
|
|
|
|
cmd := c.client.B().Hset().Key(KeyTasksCompleted).FieldValue().FieldValue(result.TaskID, string(data)).Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// GetTaskResult retrieves a task result from the completed hash
|
|
func (c *Client) GetTaskResult(ctx context.Context, taskID string) (*TaskResult, error) {
|
|
cmd := c.client.B().Hget().Key(KeyTasksCompleted).Field(taskID).Build()
|
|
data, err := c.client.Do(ctx, cmd).ToString()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to get task result: %w", err)
|
|
}
|
|
|
|
return UnmarshalTaskResult([]byte(data))
|
|
}
|
|
|
|
// GetRunningTask retrieves a running task by ID
|
|
func (c *Client) GetRunningTask(ctx context.Context, taskID string) (*Task, error) {
|
|
cmd := c.client.B().Hget().Key(KeyTasksRunning).Field(taskID).Build()
|
|
data, err := c.client.Do(ctx, cmd).ToString()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to get running task: %w", err)
|
|
}
|
|
|
|
return UnmarshalTask([]byte(data))
|
|
}
|
|
|
|
// GetAllRunningTasks retrieves all running tasks
|
|
func (c *Client) GetAllRunningTasks(ctx context.Context) ([]*Task, error) {
|
|
cmd := c.client.B().Hgetall().Key(KeyTasksRunning).Build()
|
|
result, err := c.client.Do(ctx, cmd).AsStrMap()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get running tasks: %w", err)
|
|
}
|
|
|
|
var tasks []*Task
|
|
for _, data := range result {
|
|
task, err := UnmarshalTask([]byte(data))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
tasks = append(tasks, task)
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|
|
|
|
// RegisterWorker registers a worker in the workers hash
|
|
func (c *Client) RegisterWorker(ctx context.Context, worker *WorkerInfo) error {
|
|
data, err := worker.MarshalJSON()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal worker: %w", err)
|
|
}
|
|
|
|
cmd := c.client.B().Hset().Key(KeyWorkers).FieldValue().FieldValue(worker.ID, string(data)).Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// UpdateWorkerHeartbeat updates a worker's heartbeat timestamp
|
|
func (c *Client) UpdateWorkerHeartbeat(ctx context.Context, workerID string) error {
|
|
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
|
cmd := c.client.B().Hset().Key(KeyWorkersHeartbeat).FieldValue().FieldValue(workerID, timestamp).Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// GetWorkerHeartbeat gets a worker's last heartbeat timestamp
|
|
func (c *Client) GetWorkerHeartbeat(ctx context.Context, workerID string) (time.Time, error) {
|
|
cmd := c.client.B().Hget().Key(KeyWorkersHeartbeat).Field(workerID).Build()
|
|
data, err := c.client.Do(ctx, cmd).ToString()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return time.Time{}, nil
|
|
}
|
|
return time.Time{}, err
|
|
}
|
|
|
|
ts, err := strconv.ParseInt(data, 10, 64)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
return time.Unix(ts, 0), nil
|
|
}
|
|
|
|
// GetAllWorkers retrieves all registered workers
|
|
func (c *Client) GetAllWorkers(ctx context.Context) ([]*WorkerInfo, error) {
|
|
cmd := c.client.B().Hgetall().Key(KeyWorkers).Build()
|
|
result, err := c.client.Do(ctx, cmd).AsStrMap()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get workers: %w", err)
|
|
}
|
|
|
|
var workers []*WorkerInfo
|
|
for _, data := range result {
|
|
worker, err := UnmarshalWorkerInfo([]byte(data))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
workers = append(workers, worker)
|
|
}
|
|
|
|
return workers, nil
|
|
}
|
|
|
|
// GetWorker retrieves a single worker by ID
|
|
func (c *Client) GetWorker(ctx context.Context, workerID string) (*WorkerInfo, error) {
|
|
cmd := c.client.B().Hget().Key(KeyWorkers).Field(workerID).Build()
|
|
data, err := c.client.Do(ctx, cmd).ToString()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to get worker: %w", err)
|
|
}
|
|
return UnmarshalWorkerInfo([]byte(data))
|
|
}
|
|
|
|
// GetWorkerByAlias retrieves a worker by its alias
|
|
func (c *Client) GetWorkerByAlias(ctx context.Context, alias string) (*WorkerInfo, error) {
|
|
workers, err := c.GetAllWorkers(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, w := range workers {
|
|
if w.Alias == alias {
|
|
return w, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// RemoveWorker removes a worker from the registry
|
|
func (c *Client) RemoveWorker(ctx context.Context, workerID string) error {
|
|
// Remove from both workers and heartbeat hashes
|
|
cmd1 := c.client.B().Hdel().Key(KeyWorkers).Field(workerID).Build()
|
|
cmd2 := c.client.B().Hdel().Key(KeyWorkersHeartbeat).Field(workerID).Build()
|
|
|
|
if err := c.client.Do(ctx, cmd1).Error(); err != nil {
|
|
return err
|
|
}
|
|
return c.client.Do(ctx, cmd2).Error()
|
|
}
|
|
|
|
// AcquireMasterLock tries to acquire the master lock
|
|
func (c *Client) AcquireMasterLock(ctx context.Context, masterID string, ttl time.Duration) (bool, error) {
|
|
cmd := c.client.B().Set().Key(KeyMasterLock).Value(masterID).Nx().Ex(ttl).Build()
|
|
result, err := c.client.Do(ctx, cmd).ToString()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return false, nil // Lock not acquired
|
|
}
|
|
return false, err
|
|
}
|
|
return result == "OK", nil
|
|
}
|
|
|
|
// RefreshMasterLock refreshes the master lock TTL
|
|
func (c *Client) RefreshMasterLock(ctx context.Context, masterID string, ttl time.Duration) error {
|
|
// Only refresh if we still own the lock
|
|
cmd := c.client.B().Get().Key(KeyMasterLock).Build()
|
|
current, err := c.client.Do(ctx, cmd).ToString()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current != masterID {
|
|
return fmt.Errorf("master lock lost")
|
|
}
|
|
|
|
expireCmd := c.client.B().Expire().Key(KeyMasterLock).Seconds(int64(ttl.Seconds())).Build()
|
|
return c.client.Do(ctx, expireCmd).Error()
|
|
}
|
|
|
|
// ReleaseMasterLock releases the master lock
|
|
func (c *Client) ReleaseMasterLock(ctx context.Context, masterID string) error {
|
|
// Only release if we own the lock
|
|
cmd := c.client.B().Get().Key(KeyMasterLock).Build()
|
|
current, err := c.client.Do(ctx, cmd).ToString()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return nil // Already released
|
|
}
|
|
return err
|
|
}
|
|
if current != masterID {
|
|
return nil // Not our lock
|
|
}
|
|
|
|
delCmd := c.client.B().Del().Key(KeyMasterLock).Build()
|
|
return c.client.Do(ctx, delCmd).Error()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Event Pub/Sub Methods
|
|
// =============================================================================
|
|
|
|
// PublishEvent publishes an event to a topic channel
|
|
func (c *Client) PublishEvent(ctx context.Context, topic string, event *core.Event) error {
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal event: %w", err)
|
|
}
|
|
|
|
channel := KeyEventsPrefix + topic
|
|
cmd := c.client.B().Publish().Channel(channel).Message(string(data)).Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// SubscribeEvents subscribes to event channels with pattern and calls handler for each event.
|
|
// This method blocks until the context is cancelled or an error occurs.
|
|
func (c *Client) SubscribeEvents(ctx context.Context, handler func(*core.Event)) error {
|
|
pattern := KeyEventsPrefix + "*"
|
|
|
|
// Use Receive with PSUBSCRIBE - this blocks and calls handler for each message
|
|
err := c.client.Receive(ctx, c.client.B().Psubscribe().Pattern(pattern).Build(),
|
|
func(msg rueidis.PubSubMessage) {
|
|
if msg.Message != "" {
|
|
var event core.Event
|
|
if err := json.Unmarshal([]byte(msg.Message), &event); err == nil {
|
|
handler(&event)
|
|
}
|
|
}
|
|
})
|
|
|
|
if err != nil && ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// =============================================================================
|
|
// Data Queue Methods (Worker -> Master)
|
|
// =============================================================================
|
|
|
|
// DataEnvelope wraps data with type information for queue processing
|
|
type DataEnvelope struct {
|
|
Type string `json:"type"`
|
|
Data json.RawMessage `json:"data"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
WorkerID string `json:"worker_id,omitempty"`
|
|
}
|
|
|
|
// PushData pushes data to a worker data queue
|
|
func (c *Client) PushData(ctx context.Context, key string, dataType string, data interface{}, workerID string) error {
|
|
dataBytes, err := json.Marshal(data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal data: %w", err)
|
|
}
|
|
|
|
envelope := DataEnvelope{
|
|
Type: dataType,
|
|
Data: dataBytes,
|
|
Timestamp: time.Now(),
|
|
WorkerID: workerID,
|
|
}
|
|
|
|
envBytes, err := json.Marshal(envelope)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal envelope: %w", err)
|
|
}
|
|
|
|
cmd := c.client.B().Lpush().Key(key).Element(string(envBytes)).Build()
|
|
return c.client.Do(ctx, cmd).Error()
|
|
}
|
|
|
|
// PopData pops data from a queue (blocking with timeout)
|
|
func (c *Client) PopData(ctx context.Context, key string, timeout time.Duration) (*DataEnvelope, error) {
|
|
cmd := c.client.B().Brpop().Key(key).Timeout(timeout.Seconds()).Build()
|
|
result, err := c.client.Do(ctx, cmd).AsStrSlice()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return nil, nil // Timeout, no data available
|
|
}
|
|
return nil, fmt.Errorf("failed to pop data: %w", err)
|
|
}
|
|
|
|
if len(result) < 2 {
|
|
return nil, nil // No data
|
|
}
|
|
|
|
var envelope DataEnvelope
|
|
if err := json.Unmarshal([]byte(result[1]), &envelope); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal envelope: %w", err)
|
|
}
|
|
|
|
return &envelope, nil
|
|
}
|
|
|
|
// PopDataMulti pops data from multiple queues (blocking with timeout)
|
|
// Returns the key that had data and the data envelope
|
|
func (c *Client) PopDataMulti(ctx context.Context, timeout time.Duration, keys ...string) (string, *DataEnvelope, error) {
|
|
cmd := c.client.B().Brpop().Key(keys[0])
|
|
for _, k := range keys[1:] {
|
|
cmd = cmd.Key(k)
|
|
}
|
|
cmdBuilt := cmd.Timeout(timeout.Seconds()).Build()
|
|
|
|
result, err := c.client.Do(ctx, cmdBuilt).AsStrSlice()
|
|
if err != nil {
|
|
if rueidis.IsRedisNil(err) {
|
|
return "", nil, nil // Timeout, no data available
|
|
}
|
|
return "", nil, fmt.Errorf("failed to pop data: %w", err)
|
|
}
|
|
|
|
if len(result) < 2 {
|
|
return "", nil, nil // No data
|
|
}
|
|
|
|
var envelope DataEnvelope
|
|
if err := json.Unmarshal([]byte(result[1]), &envelope); err != nil {
|
|
return "", nil, fmt.Errorf("failed to unmarshal envelope: %w", err)
|
|
}
|
|
|
|
return result[0], &envelope, nil
|
|
}
|
|
|
|
// GetQueueLength returns the length of a data queue
|
|
func (c *Client) GetQueueLength(ctx context.Context, key string) (int64, error) {
|
|
cmd := c.client.B().Llen().Key(key).Build()
|
|
return c.client.Do(ctx, cmd).AsInt64()
|
|
}
|