package tasks import ( "context" "database/sql" "encoding/json" "fmt" "log/slog" "time" ) // QueueSpec names a task queue and its backing SQLite table. type QueueSpec struct { Name string Table string } // TaskRow is one task record as returned by the MetricEmitter. type TaskRow struct { TaskID string Queue string Status string Attempts int Created time.Time RunAfter time.Time Payload string } // QueueSnapshot is a point-in-time count of tasks by status for one queue, // along with the individual task rows. type QueueSnapshot struct { Name string Pending int Processing int Completed int Failed int Tasks []TaskRow } // MetricEmitter queries task queue depths on an interval and logs them as // structured events for the dev server to pick up and relay to the dev UI. type MetricEmitter struct { DB *sql.DB Queues []QueueSpec Interval time.Duration } func (e *MetricEmitter) Run(ctx context.Context) { interval := e.Interval if interval <= 0 { interval = 3 * time.Second } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: e.emit(ctx) } } } func (e *MetricEmitter) emit(ctx context.Context) { snapshots := make([]QueueSnapshot, 0, len(e.Queues)) for _, q := range e.Queues { s, err := queryQueueSnapshot(ctx, e.DB, q) if err != nil { slog.Warn("task queue metrics error", "queue", q.Name, "err", err) continue } snapshots = append(snapshots, s) } data, err := json.Marshal(snapshots) if err != nil { return } slog.Info("task queue metrics", "queues", json.RawMessage(data)) } const sqliteTimeLayout = "2006-01-02 15:04:05" func queryQueueSnapshot(ctx context.Context, db *sql.DB, q QueueSpec) (QueueSnapshot, error) { rows, err := db.QueryContext(ctx, "SELECT LOWER(status), COUNT(*) FROM "+q.Table+" GROUP BY LOWER(status)") if err != nil { return QueueSnapshot{}, fmt.Errorf("querying %s: %w", q.Table, err) } defer rows.Close() s := QueueSnapshot{Name: q.Name} for rows.Next() { var status string var count int if err := rows.Scan(&status, &count); err != nil { return s, err } switch status { case "pending": s.Pending = count case "processing": s.Processing = count case "completed": s.Completed = count case "failed": s.Failed = count } } if err := rows.Err(); err != nil { return s, err } taskRows, err := db.QueryContext(ctx, "SELECT task_id, status, attempts, created_at, run_after, payload FROM "+q.Table+ " ORDER BY created_at DESC, task_id DESC LIMIT 50") if err != nil { return s, fmt.Errorf("querying tasks %s: %w", q.Table, err) } defer taskRows.Close() for taskRows.Next() { var row TaskRow var createdStr, runAfterStr string if err := taskRows.Scan(&row.TaskID, &row.Status, &row.Attempts, &createdStr, &runAfterStr, &row.Payload); err != nil { return s, err } row.Queue = q.Name row.Created, _ = time.ParseInLocation(sqliteTimeLayout, createdStr, time.UTC) row.RunAfter, _ = time.ParseInLocation(sqliteTimeLayout, runAfterStr, time.UTC) s.Tasks = append(s.Tasks, row) } return s, taskRows.Err() }