🧠 Task Queue / Worker Pool — Thought Process
📊 Class Diagram
classDiagram
class Queue {
-handlers map string to Handler
-backoff func attempt to Duration
-workers int
-baseCtx context.Context
-hardStop context.CancelFunc
-mu sync.Mutex
-tasks map string to task
-ready taskHeap
-delayed taskHeap
-changed chan struct
-outstanding int
-closing bool
-deadLetters []TaskResult
-wg sync.WaitGroup
+Start()
+Submit(spec TaskSpec) string, error
+Cancel(id string) error
+Wait(ctx Context, id string) TaskResult, error
+Shutdown(ctx Context) error
+Stats() Stats
+DeadLetters() []TaskResult
-worker()
-acquire() task, wake, wait
-finish(t task, v any, err error)
-finalizeLocked(t task, st Status, v any, err error)
}
class Options {
+Workers int
+Handlers map string to Handler
+Backoff func attempt to Duration
}
class Handler {
<<interface>>
+Handle(ctx Context, payload any) any, error
}
class HandlerFunc {
<<func type>>
+Handle(ctx Context, payload any) any, error
}
class TaskSpec {
+ID string
+Type string
+Payload any
+Priority Priority
+MaxAttempts int
+Timeout Duration
+RunAt Time
+DependsOn []string
}
class task {
-spec TaskSpec
-seq uint64
-status Status
-attempts int
-runAt Time
-heapIdx int
-waitingOn int
-dependents []task
-cancel context.CancelFunc
-cancelRequested bool
-done chan struct
}
class taskHeap {
-items []task
-less func a, b to bool
+Len() int
+Push(x any)
+Pop() any
}
class TaskResult {
+ID string
+Status Status
+Value any
+Err error
+Attempts int
}
class Status {
<<enumeration>>
BLOCKED
SCHEDULED
READY
RUNNING
SUCCEEDED
FAILED
CANCELLED
+Terminal() bool
}
class Priority {
<<enumeration>>
PriorityLow
PriorityNormal
PriorityHigh
PriorityCritical
}
class permanentError {
-err error
+Error() string
+Unwrap() error
}
class Stats {
+ByStatus map Status to int
+Retries int
+DeadLetters int
}
Queue ..> Options : New(opts)
Queue o-- "*" Handler : by task type
HandlerFunc ..|> Handler
Queue "1" *-- "*" task : tasks index
Queue *-- "2" taskHeap : ready by priority, delayed by runAt
taskHeap o-- "*" task
task *-- TaskSpec
task --> Status
task --> "*" task : dependents
TaskSpec --> Priority
Queue ..> TaskResult : Wait, dead letters
Queue ..> Stats
Queue ..> permanentError : no retry
Problem Breakdown
Step 1: Core Components
- TaskSpec: type, payload, priority, max attempts, per-attempt timeout, optional
RunAt and DependsOn.
- Queue: a priority heap (priority, then FIFO by sequence number) plus a delayed heap ordered by
runAt.
- Workers: N goroutines that pull. When idle they block on a broadcast channel, not a poll loop.
- Handler: one per task type (Strategy). Must honour
ctx.
Step 2: Priority Ordering
- A heap gives O(log n) push and pop.
- Break ties with a monotonic sequence number, not a timestamp, and never with
time.Now() inside Less.
Step 3: Retry with Backoff
- Retry transient errors up to
MaxAttempts. Fail Permanent errors and panics immediately.
- Exponential backoff with full jitter. Backed-off tasks wait in the delayed heap, where
Cancel and Shutdown can still find them.
- Out of attempts →
FAILED + dead-letter list.
Step 4: Dependencies
- Dependencies must already exist, so the graph is a DAG by construction.
- Blocked tasks stay out of the ready heap. Success unblocks dependents. Failure or cancel cascades.
Step 5: Graceful Shutdown
- Stop accepting → drain (including retries) → if the deadline passes, cancel the base context and mark the rest
CANCELLED.
- Prove there are no goroutine leaks in a test.
Key Decisions
| Decision |
Why |
| One mutex, handlers run unlocked |
Tiny critical sections; simplest correct design |
| Close-and-replace "changed" channel |
Broadcast wake-up that composes with select, timers and ctx |
Delayed heap instead of time.AfterFunc |
Every waiting task stays cancellable and visible to shutdown |
| Sequence number tie-break |
Deterministic, strict total order |
Per-task done channel + Wait |
No shared results channel that can fill up and block workers |
Permanent(err) wrapper |
The handler decides what's retryable; the queue stays generic |
Idempotent Submit on ID |
Safe client retries of the submit call itself |
⏱️ How to run this in a 45–60 min interview
| Time |
Phase |
What to do |
What to say out loud |
| 0–5 |
Clarify |
Questions below; write Submit/Cancel/Wait/Shutdown |
"In-process, at-least-once, handlers are idempotent. I'll call out what changes when it becomes distributed." |
| 5–12 |
Entities |
TaskSpec, task, Handler, Queue, status enum + state diagram |
"Every terminal transition goes through one function, so done-channel closing and cascades happen exactly once." |
| 12–25 |
Core |
Priority heap + N workers + Submit, with no retries yet |
"Workers pull under one mutex; handlers run outside it." |
| 25–35 |
Concurrency |
Wake-up mechanism; Shutdown(ctx) |
"No polling: I broadcast by closing a channel. Shutdown drains, then hard-cancels at the deadline." |
| 35–45 |
Retries |
Backoff into the delayed heap, Permanent, dead letters |
"No AfterFunc: a timer callback after shutdown is a leak." |
| 45–60 |
Extension |
Dependencies, cancel, per-type limits, or "make it distributed" |
Show that the change is local; mention leases and idempotency. |
Clarifying questions worth asking
- In-process library or a distributed service? Must tasks survive a crash?
- Delivery guarantee: at-most-once or at-least-once? Are handlers idempotent?
- Is priority strict, or must low priority get a minimum share (starvation)?
- Retry policy: how many attempts, which errors are retryable, is there a backoff cap?
- Do we need delayed or cron tasks, dependencies, cancellation of running tasks?
- What should shutdown do with queued work: drain, persist, or drop?