Skip to content

Latest commit

 

History

History
254 lines (184 loc) · 9.05 KB

File metadata and controls

254 lines (184 loc) · 9.05 KB

backstage — Background Task Package

A standalone, zero-pocketbooth-dependency Go package for managing background jobs and scheduled tasks. Designed to be open-sourced as an independent module later.


Goals

  • One-off jobs dispatched onto named queues (worker pools)
  • Scheduled tasks with cron-style or interval-based triggers
  • Scheduled tasks can target a queue or run outside any queue
  • Supervisor implements suture.Service (Serve(ctx context.Context) error) for drop-in integration
  • Zero dependencies on pocketbooth — pure stdlib, compatible with any Go application
  • Graceful shutdown: all in-flight jobs finish before Serve returns
  • Structured logging via log/slog: optional caller-supplied logger, falls back to slog.Default()

Core Concepts

Supervisor

The top-level manager. Owns all queues and scheduled tasks. Implements suture.Service so it can be passed directly to a suture supervisor tree.

// Minimal — uses slog.Default()
sv := backstage.New("backstage")

// With a custom logger
sv := backstage.New("backstage", backstage.WithLogger(myLogger))

supervisor.Add(sv)          // just like pb or httpServer
supervisor.Serve(ctx)

New accepts functional options (...Option). The only option for now is WithLogger(*slog.Logger). Internally the supervisor adds a "supervisor" attribute to all its own log records so output from different backstage instances is distinguishable.

Queue

A named worker pool backed by a buffered channel. Registered on the supervisor at startup. Processes Job values concurrently up to the configured worker count.

sv.RegisterQueue("emails", backstage.QueueConfig{
    Workers: 3,
    Buffer:  100,
})

Job

The unit of work dispatched to a queue.

type Job struct {
    Name string
    Run  func(ctx context.Context) error
}

Dispatching is non-blocking (drops with a log warning when the buffer is full, same pattern as ThumbnailWorker).

sv.Dispatch("emails", backstage.Job{
    Name: "send-welcome-email",
    Run:  sendWelcomeEmail,
})

Schedule

An interface that answers: "what is the next time this should run after time T?"

type Schedule interface {
    Next(after time.Time) time.Time
}

Three built-in implementations:

Constructor Behaviour
backstage.Every(d time.Duration) Fixed interval from last run
backstage.Daily(hour, minute int) Daily at a specific wall-clock time (local TZ or UTC — configurable)
backstage.Cron(expr string) Standard 5-field cron expression

Cron format: "minute hour dom month dow" (standard unix cron, no seconds field). Implemented in-house — no external cron dependency.

ScheduledTask

Binds a Schedule to a Job. Optionally names a queue to dispatch onto; if no queue is given the task runs directly in its own goroutine.

// Runs directly, no queue
sv.Schedule(backstage.Every(24*time.Hour), backstage.Job{
    Name: "cleanup-sessions",
    Run:  cleanupSessions,
})

// Dispatched onto the "heavy" queue
sv.ScheduleOnQueue("heavy", backstage.Cron("0 3 * * *"), backstage.Job{
    Name: "archive-events",
    Run:  archiveExpiredEvents,
})

File Structure

internal/backstage/
    plan.md             — this document
    supervisor.go       — Supervisor: New (+ options), Serve, RegisterQueue, Dispatch, Schedule, ScheduleOnQueue
    queue.go            — Queue: worker pool, buffered channel, graceful drain
    job.go              — Job type
    schedule.go         — Schedule interface + Every + Daily implementations
    cron.go             — Cron schedule: 5-field expression parser and Next()
    task.go             — scheduledTask: internal binding of Schedule + Job + optional queue name
    options.go          — Option type + WithLogger functional option
    logger.go           — internal logger helper: wraps *slog.Logger, adds backstage-specific attrs

Supervisor Lifecycle (Serve)

  1. Log INFO — supervisor starting, list registered queues and scheduled tasks.
  2. Start all registered queues (launch N worker goroutines each); each worker logs DEBUG on start/stop.
  3. Start the scheduler loop (single goroutine, uses a time.Timer sleeping until the next scheduled fire); logs DEBUG — next fire time for each task on every reschedule.
  4. Block until ctx is cancelled.
  5. Log INFO — shutdown initiated.
  6. Signal all queues to stop accepting new work.
  7. Wait for in-flight jobs to complete (bounded drain — configurable DrainTimeout, default 30 s).
  8. Log INFO — supervisor stopped (with total jobs processed, if tracked).
  9. Return nil (or ctx.Err() if forced).

The scheduler goroutine never blocks on queue dispatch; if a queue buffer is full it drops at WARN level.


Logging

Setup

// Use slog.Default()
sv := backstage.New("backstage")

// Use a custom *slog.Logger
sv := backstage.New("backstage", backstage.WithLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil))))

All log calls inside backstage go through an internal wrapper that pre-attaches the supervisor name as a group attribute (backstage.<name>), so log output from multiple backstage instances is always identifiable.

Log levels used

Level When
INFO Supervisor start/stop, queue registered, task scheduled, shutdown initiated
DEBUG Worker goroutine start/stop, job picked up, job finished (with duration), next scheduled fire time, scheduler loop tick
WARN Job dropped (queue full), job returned a non-nil error
ERROR Job panicked (recovered), queue failed to start

Attributes emitted per event

Job started (DEBUG): supervisor, queue, job, worker_id

Job finished (DEBUG): supervisor, queue, job, worker_id, duration, error (omitted if nil)

Job dropped (WARN): supervisor, queue, job, queue_depth

Scheduled task fired (DEBUG): supervisor, task, queue (if any), fired_at, next_at

Shutdown drain (DEBUG): supervisor, in_flight, drain_timeout


Suture Compatibility

Supervisor has exactly one method that suture needs:

func (s *Supervisor) Serve(ctx context.Context) error

This matches suture.Service. No import of the suture package inside backstage itself — the interface is satisfied structurally (Go duck typing), keeping the package dependency-free.

Usage in cmd/server/main.go (future):

bs := backstage.New("backstage")
bs.RegisterQueue("default", backstage.QueueConfig{Workers: 2, Buffer: 64})
bs.ScheduleOnQueue("default", backstage.Cron("0 3 * * *"), backstage.Job{
    Name: "archive-expired-events",
    Run:  pb.ArchiveExpiredEvents,
})

rootSupervisor := suture.New("supervisor", suture.Spec{})
rootSupervisor.Add(pb)
rootSupervisor.Add(httpServer)
rootSupervisor.Add(bs)          // ← backstage dropped in alongside the others
rootSupervisor.Serve(appCtx)

Config / Defaults

All config lives in QueueConfig and an optional SupervisorConfig passed to New.

Setting Default Notes
DrainTimeout 30s Max time to wait for in-flight jobs on shutdown
Queue Workers 1 Worker goroutines per queue
Queue Buffer 32 Buffered channel depth

Event Archiving — Integration Plan (pocketbooth)

Once backstage is implemented, the archive flow is:

  1. Add POCKETBOOTH_EVENT_ARCHIVE_GRACE_PERIOD env var (default 720h = 30 days) to pocketbooth.Config.
  2. Implement pb.ArchiveExpiredEvents(ctx context.Context) error in the events domain — an SQL UPDATE that sets archived_at = now() on events where end_date + grace_period < now() and archived_at IS NULL.
  3. Register in cmd/server/main.go:
    bs.ScheduleOnQueue("default", backstage.Cron("0 3 * * *"), backstage.Job{
        Name: "archive-expired-events",
        Run:  pb.ArchiveExpiredEvents,
    })
  4. Events overview handler: query splits into active/future + archived, renders archived as a collapsed HTML <details> section below the main list.

Out of Scope (for now)

  • Persistent job queues (database-backed retry, dead-letter queue)
  • Job deduplication
  • Distributed locking (single-process only)
  • Metrics / instrumentation hooks (can be added later via middleware pattern on Job.Run)