Refactor MQTT course and bound backend message processing
Move lesson source into the teaching structure, align the course path with the learner's Go and DevOps experience, and add the interactive lesson guides. Add advanced DevOps lifecycle study as a separate section. Bound MQTT message work and drain accepted jobs during shutdown; align the backend implementation with the revised course through Section 6.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Outcome uint8
|
||||
|
||||
// Outcomes make queue admission success and overload visible to callers.
|
||||
const (
|
||||
Accepted Outcome = iota
|
||||
Full
|
||||
Stopping
|
||||
)
|
||||
|
||||
type Job func(context.Context) error
|
||||
|
||||
type Stats struct {
|
||||
Accepted uint64
|
||||
Rejected uint64
|
||||
Completed uint64
|
||||
Failed uint64
|
||||
}
|
||||
|
||||
// Pool owns a bounded queue, worker lifecycle, and admission counters.
|
||||
type Pool struct {
|
||||
mu sync.Mutex
|
||||
accepting bool
|
||||
jobs chan Job
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
workers sync.WaitGroup
|
||||
done chan struct{}
|
||||
|
||||
accepted atomic.Uint64
|
||||
rejected atomic.Uint64
|
||||
completed atomic.Uint64
|
||||
failed atomic.Uint64
|
||||
}
|
||||
|
||||
// New starts a fixed worker set and ties job cancellation to parent.
|
||||
func New(parent context.Context, workers, capacity int) *Pool {
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
if capacity < 0 {
|
||||
capacity = 0
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
p := &Pool{accepting: true, jobs: make(chan Job, capacity), ctx: ctx, cancel: cancel, done: make(chan struct{})}
|
||||
p.workers.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go p.worker()
|
||||
}
|
||||
go func() { p.workers.Wait(); close(p.done) }()
|
||||
return p
|
||||
}
|
||||
|
||||
// Submit never waits for queue capacity. The caller gets an explicit Full result.
|
||||
// The caller context can cancel admission before it enters the queue.
|
||||
func (p *Pool) Submit(ctx context.Context, job Job) (Outcome, error) {
|
||||
if job == nil {
|
||||
return Stopping, errors.New("nil job")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Stopping, err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if !p.accepting {
|
||||
return Stopping, nil
|
||||
}
|
||||
|
||||
select {
|
||||
|
||||
case p.jobs <- job:
|
||||
p.accepted.Add(1)
|
||||
return Accepted, nil
|
||||
default:
|
||||
p.rejected.Add(1)
|
||||
return Full, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) StopAdmission() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.accepting {
|
||||
p.accepting = false
|
||||
close(p.jobs)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops new submissions and drains queued work until ctx expires.
|
||||
// Jobs must observe their context so deadline cancellation can stop slow work.
|
||||
func (p *Pool) Shutdown(ctx context.Context) error {
|
||||
p.StopAdmission()
|
||||
select {
|
||||
case <-p.done:
|
||||
p.cancel()
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
p.cancel()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) Stats() Stats {
|
||||
return Stats{Accepted: p.accepted.Load(), Rejected: p.rejected.Load(), Completed: p.completed.Load(), Failed: p.failed.Load()}
|
||||
}
|
||||
|
||||
// Workers execute admitted jobs and count their final result.
|
||||
func (p *Pool) worker() {
|
||||
defer p.workers.Done()
|
||||
for {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
case job, ok := <-p.jobs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := job(p.ctx); err != nil {
|
||||
p.failed.Add(1)
|
||||
continue
|
||||
}
|
||||
p.completed.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/mqtt5-learning/internal/mqtt"
|
||||
)
|
||||
|
||||
func TestPoolReportsFullAndDrainsAcceptedJobs(t *testing.T) {
|
||||
pool := New(context.Background(), 1, 1)
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
outcome, err := pool.Submit(context.Background(), func(context.Context) error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
if err != nil || outcome != Accepted {
|
||||
t.Fatalf("first Submit() = (%v, %v), want (Accepted, nil)", outcome, err)
|
||||
}
|
||||
<-started
|
||||
|
||||
outcome, err = pool.Submit(context.Background(), func(context.Context) error { return nil })
|
||||
if err != nil || outcome != Accepted {
|
||||
t.Fatalf("second Submit() = (%v, %v), want (Accepted, nil)", outcome, err)
|
||||
}
|
||||
outcome, err = pool.Submit(context.Background(), func(context.Context) error { return nil })
|
||||
if err != nil || outcome != Full {
|
||||
t.Fatalf("third Submit() = (%v, %v), want (Full, nil)", outcome, err)
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
shutdownDone := make(chan error, 1)
|
||||
go func() { shutdownDone <- pool.Shutdown(shutdownCtx) }()
|
||||
|
||||
select {
|
||||
case err := <-shutdownDone:
|
||||
t.Fatalf("Shutdown() returned before accepted work was released: %v", err)
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
if err := <-shutdownDone; err != nil {
|
||||
t.Fatalf("Shutdown() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
stats := pool.Stats()
|
||||
if stats.Accepted != 2 || stats.Rejected != 1 || stats.Completed != 2 || stats.Failed != 0 {
|
||||
t.Fatalf("Stats() = %+v, want accepted=2 rejected=1 completed=2 failed=0", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRoutesResponsesToImportantPool(t *testing.T) {
|
||||
telemetry := New(context.Background(), 1, 1)
|
||||
important := New(context.Background(), 1, 1)
|
||||
receiver := &Receiver{
|
||||
Telemetry: telemetry,
|
||||
Important: important,
|
||||
Process: func(context.Context, mqtt.Message) error { return nil },
|
||||
}
|
||||
|
||||
outcome, err := receiver.Submit(context.Background(), mqtt.Message{
|
||||
Topic: "lab/v1/devices/device-001/responses",
|
||||
})
|
||||
if err != nil || outcome != Accepted {
|
||||
t.Fatalf("Submit(response) = (%v, %v), want (Accepted, nil)", outcome, err)
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := Shutdown(shutdownCtx, telemetry, important); err != nil {
|
||||
t.Fatalf("Shutdown() error = %v", err)
|
||||
}
|
||||
if got := important.Stats().Accepted; got != 1 {
|
||||
t.Errorf("important accepted = %d, want 1", got)
|
||||
}
|
||||
if got := telemetry.Stats().Accepted; got != 0 {
|
||||
t.Errorf("telemetry accepted = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"example.com/mqtt5-learning/internal/contracts"
|
||||
"example.com/mqtt5-learning/internal/mqtt"
|
||||
)
|
||||
|
||||
type Processor func(context.Context, mqtt.Message) error
|
||||
|
||||
// Receiver validates the route, selects a pool, and copies payload ownership.
|
||||
type Receiver struct {
|
||||
Telemetry *Pool
|
||||
Important *Pool
|
||||
Process Processor
|
||||
}
|
||||
|
||||
func (r *Receiver) Submit(ctx context.Context, message mqtt.Message) (Outcome, error) {
|
||||
if r.Process == nil {
|
||||
return Stopping, fmt.Errorf("message processor is required")
|
||||
}
|
||||
topic, err := contracts.ParseTopic(message.Topic)
|
||||
if err != nil {
|
||||
return Stopping, err
|
||||
}
|
||||
pool := r.Telemetry
|
||||
if topic.Kind == contracts.KindCommands || topic.Kind == contracts.KindResponses || topic.Kind == contracts.KindSales {
|
||||
pool = r.Important
|
||||
}
|
||||
if pool == nil {
|
||||
return Stopping, fmt.Errorf("no queue configured for topic kind %q", topic.Kind)
|
||||
}
|
||||
copyOfMessage := message
|
||||
copyOfMessage.Payload = append([]byte(nil), message.Payload...)
|
||||
return pool.Submit(ctx, func(jobCtx context.Context) error { return r.Process(jobCtx, copyOfMessage) })
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func Shutdown(ctx context.Context, pools ...*Pool) error {
|
||||
// Close admission on every pool before waiting for any worker to drain.
|
||||
for _, pool := range pools {
|
||||
if pool != nil {
|
||||
pool.StopAdmission()
|
||||
}
|
||||
}
|
||||
var result error
|
||||
// Share one deadline across the drain so total shutdown time stays bounded.
|
||||
for _, pool := range pools {
|
||||
if pool != nil {
|
||||
result = errors.Join(result, pool.Shutdown(ctx))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user