A standalone, zero-pocketbooth-dependency Go package for managing background jobs and scheduled tasks. Designed to be open-sourced as an independent module later.
- 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
Servereturns - Structured logging via
log/slog: optional caller-supplied logger, falls back toslog.Default()
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.
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,
})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,
})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.
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,
})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
- Log
INFO— supervisor starting, list registered queues and scheduled tasks. - Start all registered queues (launch N worker goroutines each); each worker logs
DEBUGon start/stop. - Start the scheduler loop (single goroutine, uses a
time.Timersleeping until the next scheduled fire); logsDEBUG— next fire time for each task on every reschedule. - Block until
ctxis cancelled. - Log
INFO— shutdown initiated. - Signal all queues to stop accepting new work.
- Wait for in-flight jobs to complete (bounded drain — configurable
DrainTimeout, default 30 s). - Log
INFO— supervisor stopped (with total jobs processed, if tracked). - Return
nil(orctx.Err()if forced).
The scheduler goroutine never blocks on queue dispatch; if a queue buffer is full it drops at WARN level.
// 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.
| 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 |
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
Supervisor has exactly one method that suture needs:
func (s *Supervisor) Serve(ctx context.Context) errorThis 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)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 |
Once backstage is implemented, the archive flow is:
- Add
POCKETBOOTH_EVENT_ARCHIVE_GRACE_PERIODenv var (default720h= 30 days) topocketbooth.Config. - Implement
pb.ArchiveExpiredEvents(ctx context.Context) errorin the events domain — an SQL UPDATE that setsarchived_at = now()on events whereend_date + grace_period < now()andarchived_at IS NULL. - Register in
cmd/server/main.go:bs.ScheduleOnQueue("default", backstage.Cron("0 3 * * *"), backstage.Job{ Name: "archive-expired-events", Run: pb.ArchiveExpiredEvents, })
- Events overview handler: query splits into active/future + archived, renders archived as a collapsed HTML
<details>section below the main list.
- 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)