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:
2026-10-22 10:19:56 +03:30
parent f9ab91ed60
commit fb30e8e884
70 changed files with 7492 additions and 312 deletions
+133
View File
@@ -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)
}
}
}
+83
View File
@@ -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)
}
}
+38
View File
@@ -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) })
}
+23
View File
@@ -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
}