tinyrelayfile
tinyrelay/internal/work/work.go/

internal/work/work.go raw
1package work
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "database/sql"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "math"
12 "sync"
13 "sync/atomic"
14 "time"
15
16 "github.com/FelineStateMachine/tinyrelay/internal/storage"
17 "github.com/prometheus/client_golang/prometheus"
18)
19
20const defaultLease = 30 * time.Second
21
22// ErrClaimLost denotes loss of worker lease ownership.
23var ErrClaimLost = errors.New("work: claim lost")
24
25type stopError struct{ err error }
26
27func (e *stopError) Error() string {
28 if e.err == nil {
29 return "work: stopped"
30 }
31 return e.err.Error()
32}
33
34func (e *stopError) Unwrap() error { return e.err }
35
36// Stop marks a handler failure as terminal. Workers cancel the claimed intent
37// instead of retrying it, while retaining the underlying error for operators.
38func Stop(err error) error { return &stopError{err: err} }
39
40// Intent is a durable unit of work. ClaimToken identifies the current claim;
41// ClaimUntil determines when another worker can claim an expired lease.
42// State is pending, running, completed or cancelled.
43type Intent struct {
44 ID string
45 Kind string
46 EventID string
47 Target string
48 Payload string
49 State string
50 Attempts int
51 NextAt time.Time
52 CreatedAt time.Time
53 UpdatedAt time.Time
54 LastError string
55 ClaimToken string
56 ClaimUntil time.Time
57}
58
59// Queue implements durable claiming and fencing over a storage Store.
60type Queue struct {
61 store *storage.Store
62 depth *prometheus.GaugeVec
63}
64
65// New creates a queue. Metrics returns a collector that hosts may register.
66func New(store *storage.Store) *Queue {
67 return &Queue{store: store, depth: prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "relay_work_pending", Help: "Pending durable work intents."}, []string{"kind"})}
68}
69
70// Metrics returns the per-kind pending gauge collector.
71func (q *Queue) Metrics() prometheus.Collector { return q.depth }
72
73// Enqueue inserts an intent idempotently.
74func (q *Queue) Enqueue(ctx context.Context, intent Intent) (string, error) {
75 if intent.Kind == "" || intent.EventID == "" || intent.Target == "" {
76 return "", errors.New("work: kind, event id, and target are required")
77 }
78 if intent.ID == "" {
79 intent.ID = storage.IntentID(intent.Kind, intent.EventID, intent.Target)
80 }
81 now := time.Now().Unix()
82 if intent.NextAt.IsZero() {
83 intent.NextAt = time.Unix(now, 0)
84 }
85 err := q.store.WithTx(ctx, func(tx *sql.Tx) error {
86 _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, intent.ID, intent.Kind, intent.EventID, intent.Target, intent.Payload, intent.NextAt.Unix(), now, now)
87 return err
88 })
89 if err != nil {
90 return "", fmt.Errorf("work: enqueue: %w", err)
91 }
92 if metricKind(intent.Kind) == "other" {
93 q.refresh(ctx, "")
94 } else {
95 q.refresh(ctx, intent.Kind)
96 }
97 return intent.ID, nil
98}
99
100// Claim atomically claims the oldest due pending or expired intent.
101func (q *Queue) Claim(ctx context.Context, now time.Time, lease time.Duration) (*Intent, error) {
102 if lease <= 0 {
103 lease = defaultLease
104 }
105 nowUnix := now.Unix()
106 until := now.Add(lease).Unix()
107 token, err := token()
108 if err != nil {
109 return nil, err
110 }
111 var result *Intent
112 err = q.store.WithTx(ctx, func(tx *sql.Tx) error {
113 row := tx.QueryRowContext(ctx, `WITH candidate AS (
114 SELECT id FROM work_intents
115 WHERE (state='pending' AND next_at<=?) OR (state='running' AND claim_until<=?)
116 ORDER BY next_at,created_at,id LIMIT 1
117)
118UPDATE work_intents SET state='running',attempts=attempts+1,updated_at=?,claim_token=?,claim_until=?
119WHERE id=(SELECT id FROM candidate)
120RETURNING id,kind,event_id,target,payload,state,attempts,next_at,created_at,updated_at,last_error,claim_token,claim_until`, nowUnix, nowUnix, nowUnix, token, until)
121 intent := Intent{}
122 err := scanIntent(row, &intent)
123 if errors.Is(err, sql.ErrNoRows) {
124 return nil
125 }
126 if err != nil {
127 return err
128 }
129 result = &intent
130 return nil
131 })
132 if err != nil {
133 return nil, fmt.Errorf("work: claim: %w", err)
134 }
135 if result != nil {
136 if metricKind(result.Kind) == "other" {
137 q.refresh(ctx, "")
138 } else {
139 q.refresh(ctx, result.Kind)
140 }
141 }
142 return result, nil
143}
144
145// Renew extends a claim if the token still fences the caller.
146func (q *Queue) Renew(ctx context.Context, id, claimToken string, now time.Time, lease time.Duration) (bool, error) {
147 if lease <= 0 {
148 lease = defaultLease
149 }
150 result, err := q.store.DB().ExecContext(ctx, `UPDATE work_intents SET claim_until=?,updated_at=? WHERE id=? AND state='running' AND claim_token=?`, now.Add(lease).Unix(), now.Unix(), id, claimToken)
151 if err != nil {
152 return false, fmt.Errorf("work: renew: %w", err)
153 }
154 count, err := result.RowsAffected()
155 return count == 1, err
156}
157
158// Complete marks a running intent complete if the token still fences the caller.
159func (q *Queue) Complete(ctx context.Context, id, claimToken string, now time.Time) (bool, error) {
160 result, err := q.store.DB().ExecContext(ctx, `UPDATE work_intents SET state='completed',updated_at=?,claim_token='',claim_until=0 WHERE id=? AND state='running' AND claim_token=?`, now.Unix(), id, claimToken)
161 if err != nil {
162 return false, fmt.Errorf("work: complete: %w", err)
163 }
164 count, err := result.RowsAffected()
165 if count == 1 {
166 q.refresh(ctx, "")
167 }
168 return count == 1, err
169}
170
171// CompletePendingTx marks a pending intent complete in the caller's
172// transaction. Running intents are left untouched so an active worker keeps
173// ownership of its claim.
174func CompletePendingTx(ctx context.Context, tx *sql.Tx, id string, now time.Time) (bool, error) {
175 result, err := tx.ExecContext(ctx, `UPDATE work_intents SET state='completed',updated_at=?,claim_token='',claim_until=0 WHERE id=? AND state='pending'`, now.Unix(), id)
176 if err != nil {
177 return false, fmt.Errorf("work: complete pending: %w", err)
178 }
179 count, err := result.RowsAffected()
180 return count == 1, err
181}
182
183// CancelClaim marks a running intent terminal if the claim token still fences
184// the caller.
185func (q *Queue) CancelClaim(ctx context.Context, id, claimToken string, cause error, now time.Time) (bool, error) {
186 message := ""
187 if cause != nil {
188 message = cause.Error()
189 }
190 result, err := q.store.DB().ExecContext(ctx, `UPDATE work_intents SET state='cancelled',updated_at=?,last_error=?,claim_token='',claim_until=0 WHERE id=? AND state='running' AND claim_token=?`, now.Unix(), message, id, claimToken)
191 if err != nil {
192 return false, fmt.Errorf("work: cancel claim: %w", err)
193 }
194 count, err := result.RowsAffected()
195 if count == 1 {
196 q.refresh(ctx, "")
197 }
198 return count == 1, err
199}
200
201// Retry returns a failed claim to pending with unbounded exponential backoff.
202func (q *Queue) Retry(ctx context.Context, id, claimToken string, cause error, now time.Time) (bool, error) {
203 var attempts int
204 err := q.store.DB().QueryRowContext(ctx, `SELECT attempts FROM work_intents WHERE id=? AND state='running' AND claim_token=?`, id, claimToken).Scan(&attempts)
205 if errors.Is(err, sql.ErrNoRows) {
206 return false, nil
207 }
208 if err != nil {
209 return false, fmt.Errorf("work: retry lookup: %w", err)
210 }
211 delay := backoff(id, attempts)
212 message := ""
213 if cause != nil {
214 message = cause.Error()
215 }
216 result, err := q.store.DB().ExecContext(ctx, `UPDATE work_intents SET state='pending',next_at=?,updated_at=?,last_error=?,claim_token='',claim_until=0 WHERE id=? AND state='running' AND claim_token=?`, now.Add(delay).Unix(), now.Unix(), message, id, claimToken)
217 if err != nil {
218 return false, fmt.Errorf("work: retry: %w", err)
219 }
220 count, err := result.RowsAffected()
221 if count == 1 {
222 q.refresh(ctx, "")
223 }
224 return count == 1, err
225}
226
227// RetryNow makes an intent immediately eligible, regardless of its current pending delay.
228func (q *Queue) RetryNow(ctx context.Context, id string, now time.Time) error {
229 _, err := q.store.DB().ExecContext(ctx, `UPDATE work_intents SET state='pending',next_at=?,updated_at=?,claim_token='',claim_until=0 WHERE id=? AND state!='completed' AND state!='cancelled'`, now.Unix(), now.Unix(), id)
230 if err == nil {
231 q.refresh(ctx, "")
232 }
233 return err
234}
235
236// Cancel prevents future claims. A running claim can be cancelled administratively.
237func (q *Queue) Cancel(ctx context.Context, id string, now time.Time) error {
238 _, err := q.store.DB().ExecContext(ctx, `UPDATE work_intents SET state='cancelled',updated_at=?,claim_token='',claim_until=0 WHERE id=?`, now.Unix(), id)
239 if err == nil {
240 q.refresh(ctx, "")
241 }
242 return err
243}
244
245// Pending returns pending and running intents, optionally filtered by kind.
246func (q *Queue) Pending(ctx context.Context, kind string) ([]Intent, error) {
247 return q.list(ctx, kind, "pending,running")
248}
249
250// List returns intents in the requested state; an empty state lists all states.
251func (q *Queue) List(ctx context.Context, state string) ([]Intent, error) {
252 return q.list(ctx, "", state)
253}
254
255func (q *Queue) list(ctx context.Context, kind, state string) ([]Intent, error) {
256 query := `SELECT id,kind,event_id,target,payload,state,attempts,next_at,created_at,updated_at,last_error,claim_token,claim_until FROM work_intents WHERE 1=1`
257 args := []any{}
258 if kind != "" {
259 query += " AND kind=?"
260 args = append(args, kind)
261 }
262 if state == "pending,running" {
263 query += " AND state IN ('pending','running')"
264 } else if state != "" {
265 query += " AND state=?"
266 args = append(args, state)
267 }
268 query += " ORDER BY next_at,created_at,id"
269 rows, err := q.store.DB().QueryContext(ctx, query, args...)
270 if err != nil {
271 return nil, err
272 }
273 defer rows.Close()
274 var result []Intent
275 for rows.Next() {
276 intent := Intent{}
277 if err := scanIntent(rows, &intent); err != nil {
278 return nil, err
279 }
280 result = append(result, intent)
281 }
282 return result, rows.Err()
283}
284
285func (q *Queue) refresh(ctx context.Context, kind string) {
286 if kind == "" {
287 rows, err := q.store.DB().QueryContext(ctx, `SELECT kind,count(*) FROM work_intents WHERE state='pending' GROUP BY kind`)
288 if err != nil {
289 return
290 }
291 counts := make(map[string]int)
292 for rows.Next() {
293 var value string
294 var count int
295 if rows.Scan(&value, &count) == nil {
296 counts[metricKind(value)] += count
297 }
298 }
299 _ = rows.Close()
300 for _, value := range knownMetricKinds {
301 if _, ok := counts[value]; !ok {
302 counts[value] = 0
303 }
304 }
305 for value, count := range counts {
306 q.depth.WithLabelValues(value).Set(float64(count))
307 }
308 return
309 }
310 var count int
311 if err := q.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM work_intents WHERE kind=? AND state='pending'`, kind).Scan(&count); err == nil {
312 q.depth.WithLabelValues(metricKind(kind)).Set(float64(count))
313 }
314}
315
316var knownMetricKinds = []string{"delivery", "push", "replication", "webhook", "job", "other"}
317
318func metricKind(kind string) string {
319 for _, known := range knownMetricKinds[:len(knownMetricKinds)-1] {
320 if kind == known {
321 return known
322 }
323 }
324 return "other"
325}
326
327// Handler processes one claimed intent while the worker renews its lease.
328// A nil error completes the claim; an error schedules a retry. Stop marks a
329// terminal failure. The handler observes ctx cancellation so the worker can
330// finish shutdown or release an attempt whose claim was lost. A retry can
331// repeat an external effect after an interrupted acknowledgment; handlers own
332// deduplication of those effects.
333type Handler func(ctx context.Context, intent Intent) error
334
335// WorkerOptions controls claim leases and optional handler timing observation.
336// Lease and Renew default to 30 seconds and one third of the lease.
337type WorkerOptions struct {
338 Lease time.Duration
339 Renew time.Duration
340 Observe func(kind, outcome string, duration time.Duration)
341}
342
343// PoolOptions controls a pool of durable workers. Workers less than or equal
344// to zero use the default of four.
345type PoolOptions struct {
346 Workers int
347 Worker WorkerOptions
348}
349
350const defaultWorkers = 4
351
352// RunPool runs a worker pool until ctx is cancelled or a worker reports a
353// queue error. The first queue error cancels the remaining workers and is
354// returned after they have stopped.
355func RunPool(ctx context.Context, queue *Queue, handlers map[string]Handler, options PoolOptions) error {
356 workers := options.Workers
357 if workers <= 0 {
358 workers = defaultWorkers
359 }
360 workerCtx, cancel := context.WithCancel(ctx)
361 defer cancel()
362 errCh := make(chan error, 1)
363 var group sync.WaitGroup
364 for i := 0; i < workers; i++ {
365 group.Add(1)
366 go func() {
367 defer group.Done()
368 if err := NewWorkerWithOptions(queue, handlers, options.Worker).Run(workerCtx); err != nil && workerCtx.Err() == nil {
369 select {
370 case errCh <- err:
371 cancel()
372 default:
373 }
374 }
375 }()
376 }
377 group.Wait()
378 select {
379 case err := <-errCh:
380 return err
381 default:
382 return ctx.Err()
383 }
384}
385
386// Worker owns a queue lifecycle and processes one intent at a time.
387type Worker struct {
388 queue *Queue
389 handlers map[string]Handler
390 options WorkerOptions
391}
392
393// NewWorker creates a worker with copied handlers.
394func NewWorker(queue *Queue, handlers map[string]Handler) *Worker {
395 return NewWorkerWithOptions(queue, handlers, WorkerOptions{})
396}
397
398// NewWorkerWithOptions creates a worker with explicit lease behavior.
399func NewWorkerWithOptions(queue *Queue, handlers map[string]Handler, options WorkerOptions) *Worker {
400 copyHandlers := make(map[string]Handler, len(handlers))
401 for kind, handler := range handlers {
402 copyHandlers[kind] = handler
403 }
404 if options.Lease <= 0 {
405 options.Lease = defaultLease
406 }
407 if options.Renew <= 0 {
408 options.Renew = options.Lease / 3
409 }
410 if options.Renew <= 0 {
411 options.Renew = time.Millisecond
412 }
413 return &Worker{queue: queue, handlers: copyHandlers, options: options}
414}
415
416// Run blocks until the context is cancelled or a durable queue error occurs.
417func (w *Worker) Run(ctx context.Context) error {
418 for {
419 if err := ctx.Err(); err != nil {
420 return err
421 }
422 intent, err := w.queue.Claim(ctx, time.Now(), w.options.Lease)
423 if err != nil {
424 // SQLite may report a transaction cleanup error when cancellation
425 // races a claim. The worker lifecycle is already stopping, so
426 // preserve the caller's cancellation outcome.
427 if ctx.Err() != nil {
428 return ctx.Err()
429 }
430 return err
431 }
432 if intent == nil {
433 if err := wait(ctx, 100*time.Millisecond); err != nil {
434 return err
435 }
436 continue
437 }
438 handler := w.handlers[intent.Kind]
439 if handler == nil {
440 handler = func(context.Context, Intent) error { return errors.New("no handler registered") }
441 }
442 attemptCtx, cancelAttempt := context.WithCancel(ctx)
443 var lost atomic.Bool
444 renewDone := make(chan struct{})
445 go w.renew(attemptCtx, intent, cancelAttempt, &lost, renewDone)
446 started := time.Now()
447 err = handler(attemptCtx, *intent)
448 cancelAttempt()
449 <-renewDone
450 if w.options.Observe != nil {
451 outcome := "success"
452 if err != nil {
453 outcome = "error"
454 }
455 if lost.Load() {
456 outcome = "fenced"
457 }
458 w.options.Observe(intent.Kind, outcome, time.Since(started))
459 }
460 if ctx.Err() != nil {
461 return ctx.Err()
462 }
463 if lost.Load() {
464 continue
465 }
466 if err != nil {
467 var terminal *stopError
468 if errors.As(err, &terminal) {
469 ok, cancelErr := w.queue.CancelClaim(ctx, intent.ID, intent.ClaimToken, terminal.err, time.Now())
470 if cancelErr != nil {
471 return cancelErr
472 }
473 if !ok {
474 continue
475 }
476 continue
477 }
478 ok, retryErr := w.queue.Retry(ctx, intent.ID, intent.ClaimToken, err, time.Now())
479 if retryErr != nil {
480 return retryErr
481 }
482 if !ok {
483 // A cancellation or another claimant may have fenced this
484 // attempt while the handler was unwinding. Its result is stale;
485 // keep the worker alive for other durable work.
486 continue
487 }
488 continue
489 }
490 ok, err := w.queue.Complete(ctx, intent.ID, intent.ClaimToken, time.Now())
491 if err != nil {
492 return err
493 }
494 if !ok {
495 // The claim can be fenced after the renewal loop's final check.
496 // Discard this stale result and continue processing the queue.
497 continue
498 }
499 }
500}
501
502func (w *Worker) renew(ctx context.Context, intent *Intent, cancel context.CancelFunc, lost *atomic.Bool, done chan<- struct{}) {
503 defer close(done)
504 ticker := time.NewTicker(w.options.Renew)
505 defer ticker.Stop()
506 for {
507 select {
508 case <-ctx.Done():
509 return
510 case now := <-ticker.C:
511 ok, err := w.queue.Renew(ctx, intent.ID, intent.ClaimToken, now, w.options.Lease)
512 if err != nil || !ok {
513 lost.Store(true)
514 cancel()
515 return
516 }
517 }
518 }
519}
520
521func wait(ctx context.Context, duration time.Duration) error {
522 timer := time.NewTimer(duration)
523 defer timer.Stop()
524 select {
525 case <-ctx.Done():
526 return ctx.Err()
527 case <-timer.C:
528 return nil
529 }
530}
531
532type scanner interface{ Scan(...any) error }
533
534func scanIntent(row scanner, intent *Intent) error {
535 var next, created, updated, until int64
536 err := row.Scan(&intent.ID, &intent.Kind, &intent.EventID, &intent.Target, &intent.Payload, &intent.State, &intent.Attempts, &next, &created, &updated, &intent.LastError, &intent.ClaimToken, &until)
537 if err != nil {
538 return err
539 }
540 intent.NextAt, intent.CreatedAt, intent.UpdatedAt, intent.ClaimUntil = time.Unix(next, 0), time.Unix(created, 0), time.Unix(updated, 0), time.Unix(until, 0)
541 return nil
542}
543
544func token() (string, error) {
545 var bytes [16]byte
546 if _, err := rand.Read(bytes[:]); err != nil {
547 return "", fmt.Errorf("work: claim token: %w", err)
548 }
549 return hex.EncodeToString(bytes[:]), nil
550}
551
552func backoff(id string, attempts int) time.Duration {
553 if attempts < 1 {
554 attempts = 1
555 }
556 seconds := math.Pow(2, float64(attempts))
557 if seconds > float64(time.Hour/time.Second) {
558 seconds = float64(time.Hour / time.Second)
559 }
560 sum := sha256.Sum256([]byte(id + fmt.Sprint(attempts)))
561 jitter := (float64(sum[0])/255 - 0.5) * 0.4
562 return time.Duration(seconds*(1+jitter)) * time.Second
563}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs