Frontend Labs
⋯

41. Capstone: a distributed-style job queue with persistence

Goal: combine channels, workers, context, encoding/json, and file persistence into a small but real job queue — the kind of tool that sits behind "process this batch of uploaded files" or "send this batch of emails" in a real backend.

// queue/job.go
package queue

type Status string

const (
	Pending   Status = "pending"
	Running   Status = "running"
	Succeeded Status = "succeeded"
	Failed    Status = "failed"
)

type Job struct {
	ID      string `json:"id"`
	Payload string `json:"payload"`
	Status  Status `json:"status"`
	Error   string `json:"error,omitempty"`
}
// queue/queue.go
package queue

import (
	"context"
	"encoding/json"
	"os"
	"sync"
)

type Queue struct {
	mu       sync.Mutex
	jobs     map[string]*Job
	pending  chan *Job
	statePath string
}

func New(statePath string, bufferSize int) *Queue {
	q := &Queue{
		jobs:      map[string]*Job{},
		pending:   make(chan *Job, bufferSize),
		statePath: statePath,
	}
	_ = q.load() // teaching example: production code should return/log this error
	return q
}

func (q *Queue) Enqueue(id, payload string) *Job {
	q.mu.Lock()
	defer q.mu.Unlock()
	j := &Job{ID: id, Payload: payload, Status: Pending}
	q.jobs[id] = j
	q.pending <- j
	_ = q.persistLocked()
	return j
}

func (q *Queue) Get(id string) (*Job, bool) {
	q.mu.Lock()
	defer q.mu.Unlock()
	j, ok := q.jobs[id]
	return j, ok
}

func (q *Queue) Run(ctx context.Context, workers int, handler func(string) error) {
	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for {
				select {
				case <-ctx.Done():
					return
				case j, ok := <-q.pending:
					if !ok {
						return
					}
					q.process(j, handler)
				}
			}
		}()
	}
	wg.Wait()
}

func (q *Queue) process(j *Job, handler func(string) error) {
	q.setStatus(j, Running, "")
	if err := handler(j.Payload); err != nil {
		q.setStatus(j, Failed, err.Error())
		return
	}
	q.setStatus(j, Succeeded, "")
}

func (q *Queue) setStatus(j *Job, status Status, errMsg string) {
	q.mu.Lock()
	defer q.mu.Unlock()
	j.Status = status
	j.Error = errMsg
	_ = q.persistLocked()
}

// caller must hold q.mu
func (q *Queue) persistLocked() error {
	f, err := os.Create(q.statePath)
	if err != nil {
		return err
	}
	if err := json.NewEncoder(f).Encode(q.jobs); err != nil {
		_ = f.Close()
		return err
	}
	return f.Close()
}

func (q *Queue) load() error {
	f, err := os.Open(q.statePath)
	if err != nil {
		if os.IsNotExist(err) {
			return nil // no state file yet — fresh start
		}
		return err
	}
	defer f.Close()
	if err := json.NewDecoder(f).Decode(&q.jobs); err != nil {
		return err
	}
	for _, j := range q.jobs {
		if j.Status == Pending || j.Status == Running {
			j.Status = Pending
			q.pending <- j // re-enqueue anything that didn't finish last run
		}
	}
	return nil
}
// cmd/jobqueue/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"os/signal"
	"syscall"
	"time"

	"example.com/jobqueue/queue"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	q := queue.New("jobs.json", 100)

	for i := 0; i < 5; i++ {
		q.Enqueue(fmt.Sprintf("job-%d", i), fmt.Sprintf("payload-%d", i))
	}

	q.Run(ctx, 3, func(payload string) error {
		log.Println("processing", payload)
		time.Sleep(500 * time.Millisecond)
		return nil
	})
}

This gives you: a buffered channel as the in-memory work queue, persistence to disk on every state change (so a crash doesn't silently lose which jobs were pending), and signal.NotifyContext (Go 1.16+) to turn Ctrl-C into a clean, cancellable shutdown instead of an abrupt kill. Scaling this to actually distributed (multiple processes, not just multiple goroutines) means swapping the in-memory channel for a shared backend — Redis, PostgreSQL with SELECT ... FOR UPDATE SKIP LOCKED, or a message broker — while keeping the exact same Job/Status model.


Generated from real-life-golang-guide.md · 8 Oct 2026 16:36