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:
@@ -130,8 +130,8 @@ func (c Config) Validate() error {
|
||||
if strings.TrimSpace(c.MQTT.Password) == "" {
|
||||
return fmt.Errorf("mqtt.password is required in the TOML configuration")
|
||||
}
|
||||
if c.MQTT.DeviceID == "" || strings.ContainsAny(c.MQTT.DeviceID, "/+# \t\r\n") {
|
||||
return fmt.Errorf("device ID must be a nonempty single topic segment")
|
||||
if !validDeviceID(c.MQTT.DeviceID) {
|
||||
return fmt.Errorf("device ID must be 1-128 ASCII letters, digits, dots, underscores, or hyphens")
|
||||
}
|
||||
d, err := time.ParseDuration(c.App.ConnectTimeout)
|
||||
if err != nil || d <= 0 || d > time.Minute {
|
||||
@@ -139,3 +139,17 @@ func (c Config) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validDeviceID mirrors the topic contract's single-segment ID rules so
|
||||
// configuration fails before the process tries to build MQTT topics.
|
||||
func validDeviceID(value string) bool {
|
||||
if value == "" || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateRejectsDeviceIDThatCannotFormTopic(t *testing.T) {
|
||||
cfg := Config{}
|
||||
cfg.MQTT.URL = "mqtt://127.0.0.1:18883"
|
||||
cfg.MQTT.ClientID = "lab-client"
|
||||
cfg.MQTT.Username = "backend"
|
||||
cfg.MQTT.Password = "lab-password"
|
||||
cfg.MQTT.DeviceID = "device!001"
|
||||
cfg.App.ConnectTimeout = "10s"
|
||||
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate accepted a device ID rejected by the topic contract")
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestCommandRejectsTopicIdentityMismatch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := Command{SchemaVersion: SchemaVersion, MessageID: "m-1", DeviceID: "device-002", OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: "c-1", Command: "ping"}
|
||||
command := Command{SchemaVersion: SchemaVersionOne, MessageID: "m-1", DeviceID: "device-002", OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: "c-1", Command: "ping"}
|
||||
if err := command.Validate(topic, time.Now().UTC()); err == nil {
|
||||
t.Fatal("topic/body identity mismatch was accepted")
|
||||
}
|
||||
|
||||
@@ -2,27 +2,13 @@ package contracts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SchemaVersion = 1
|
||||
const SchemaVersionOne = 1
|
||||
|
||||
// Kind values match the final topic segment used to route each message.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindTelemetry Kind = "telemetry"
|
||||
KindCommands Kind = "commands"
|
||||
KindResponses Kind = "responses"
|
||||
KindSales Kind = "sales"
|
||||
)
|
||||
|
||||
// Topic is the parsed device scope and message kind from an MQTT topic.
|
||||
type Topic struct {
|
||||
DeviceID string
|
||||
Kind Kind
|
||||
}
|
||||
// SchemaVersion keeps the teaching examples' original package-level name.
|
||||
const SchemaVersion = SchemaVersionOne
|
||||
|
||||
// MQTTMessage is implemented by each versioned message contract.
|
||||
// Validation uses the parsed route and caller-supplied time for deterministic checks.
|
||||
@@ -72,22 +58,9 @@ type SalesReport struct {
|
||||
Items []SalesLine `json:"items"`
|
||||
}
|
||||
|
||||
// validID limits identifiers to safe, single-segment topic values.
|
||||
func validID(value string) bool {
|
||||
if value == "" || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validateEnvelope checks fields shared by every versioned message.
|
||||
func validateEnvelope(topic Topic, version int, messageID, deviceID, occurredAt string, now time.Time) error {
|
||||
if version != SchemaVersion {
|
||||
if version != SchemaVersionOne {
|
||||
return fmt.Errorf("unsupported schema_version %d", version)
|
||||
}
|
||||
if !validID(messageID) || !validID(deviceID) {
|
||||
@@ -163,18 +136,3 @@ func (m SalesReport) Validate(topic Topic, now time.Time) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseTopic turns broker routing metadata into values handlers can compare.
|
||||
func ParseTopic(value string) (Topic, error) {
|
||||
parts := strings.Split(value, "/")
|
||||
if len(parts) != 5 || parts[0] != "lab" || parts[1] != "v1" || parts[2] != "devices" || !validID(parts[3]) {
|
||||
return Topic{}, fmt.Errorf("topic must be lab/v1/devices/{deviceID}/{kind}")
|
||||
}
|
||||
kind := Kind(parts[4])
|
||||
switch kind {
|
||||
case KindTelemetry, KindCommands, KindResponses, KindSales:
|
||||
return Topic{DeviceID: parts[3], Kind: kind}, nil
|
||||
default:
|
||||
return Topic{}, fmt.Errorf("unsupported topic kind %q", kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TopicName is a concrete MQTT topic used by PUBLISH. It never contains MQTT
|
||||
// subscription wildcards.
|
||||
type TopicName string
|
||||
|
||||
// TopicFilter is an MQTT subscription pattern. This lab deliberately supports
|
||||
// one device-level wildcard rather than accepting arbitrary filters.
|
||||
type TopicFilter string
|
||||
|
||||
// Kind identifies the message flow in the final topic level.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindTelemetry Kind = "telemetry"
|
||||
KindCommands Kind = "commands"
|
||||
KindResponses Kind = "responses"
|
||||
KindSales Kind = "sales"
|
||||
)
|
||||
|
||||
const topicPrefix = "lab/v1/devices/"
|
||||
|
||||
// Topic is the parsed device identity and flow from a concrete topic name.
|
||||
type Topic struct {
|
||||
DeviceID string
|
||||
Kind Kind
|
||||
}
|
||||
|
||||
func (t TopicName) String() string { return string(t) }
|
||||
func (f TopicFilter) String() string { return string(f) }
|
||||
|
||||
// DeviceTopic builds the concrete topic name for one device and supported flow.
|
||||
func DeviceTopic(deviceID string, kind Kind) (TopicName, error) {
|
||||
if !validID(deviceID) {
|
||||
return "", fmt.Errorf("invalid device ID %q", deviceID)
|
||||
}
|
||||
if !supportedKind(kind) {
|
||||
return "", fmt.Errorf("unsupported topic kind %q", kind)
|
||||
}
|
||||
return TopicName(topicPrefix + deviceID + "/" + string(kind)), nil
|
||||
}
|
||||
|
||||
// DeviceFilter builds a subscription filter for one flow across lab devices.
|
||||
func DeviceFilter(kind Kind) (TopicFilter, error) {
|
||||
if !supportedKind(kind) {
|
||||
return "", fmt.Errorf("unsupported topic kind %q", kind)
|
||||
}
|
||||
return TopicFilter(topicPrefix + "+/" + string(kind)), nil
|
||||
}
|
||||
|
||||
// DeviceSubscription builds an exact-topic filter for one device and flow.
|
||||
func DeviceSubscription(deviceID string, kind Kind) (TopicFilter, error) {
|
||||
topic, err := DeviceTopic(deviceID, kind)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return TopicFilter(topic.String()), nil
|
||||
}
|
||||
|
||||
// ParseTopic turns broker routing metadata into values handlers can compare.
|
||||
func ParseTopic(value string) (Topic, error) {
|
||||
// Builders and parser share topicPrefix, so changing the route prefix does
|
||||
// not require changing the levels that identify a device and message flow.
|
||||
if !strings.HasPrefix(value, topicPrefix) {
|
||||
return Topic{}, fmt.Errorf("topic must be %s{deviceID}/{kind}", topicPrefix)
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(value, topicPrefix), "/")
|
||||
if len(parts) != 2 || !validID(parts[0]) {
|
||||
return Topic{}, fmt.Errorf("topic must be %s{deviceID}/{kind}", topicPrefix)
|
||||
}
|
||||
kind := Kind(parts[1])
|
||||
if !supportedKind(kind) {
|
||||
return Topic{}, fmt.Errorf("unsupported topic kind %q", kind)
|
||||
}
|
||||
return Topic{DeviceID: parts[0], Kind: kind}, nil
|
||||
}
|
||||
|
||||
func supportedKind(kind Kind) bool {
|
||||
switch kind {
|
||||
case KindTelemetry, KindCommands, KindResponses, KindSales:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// validID limits identifiers to safe, single-segment topic values.
|
||||
func validID(value string) bool {
|
||||
if value == "" || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package contracts
|
||||
@@ -0,0 +1,96 @@
|
||||
package deviceview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"example.com/mqtt5-learning/internal/contracts"
|
||||
"example.com/mqtt5-learning/internal/mqtt"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
logger *slog.Logger
|
||||
freshFor time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(store *Store, logger *slog.Logger, freshFor time.Duration) (*Service, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("device-view store is required")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, fmt.Errorf("logger is required")
|
||||
}
|
||||
if freshFor <= 0 {
|
||||
return nil, fmt.Errorf("heartbeat freshness window must be positive")
|
||||
}
|
||||
return &Service{store: store, logger: logger, freshFor: freshFor, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (s *Service) HandleMessage(ctx context.Context, message mqtt.Message) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
topic, err := contracts.ParseTopic(message.Topic)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse topic: %w", err)
|
||||
}
|
||||
receivedAt := s.now().UTC()
|
||||
switch topic.Kind {
|
||||
case contracts.KindTelemetry:
|
||||
heartbeat, err := contracts.Decode[contracts.Heartbeat](message.Payload, topic, receivedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate heartbeat: %w", err)
|
||||
}
|
||||
updated, err := s.store.RecordHeartbeat(heartbeat, receivedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record heartbeat: %w", err)
|
||||
}
|
||||
if !updated {
|
||||
s.logger.Info("older or duplicate heartbeat ignored",
|
||||
"device_id", heartbeat.DeviceID,
|
||||
"message_id", heartbeat.MessageID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
s.logger.Info("heartbeat recorded",
|
||||
"device_id", heartbeat.DeviceID,
|
||||
"message_id", heartbeat.MessageID,
|
||||
"received_at", receivedAt,
|
||||
)
|
||||
return nil
|
||||
case contracts.KindSales:
|
||||
report, err := contracts.Decode[contracts.SalesReport](message.Payload, topic, receivedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate sales report: %w", err)
|
||||
}
|
||||
// Validation is not durable admission; Section 13 stores reports.
|
||||
s.logger.Info("sales report validated; persistence not implemented",
|
||||
"device_id", report.DeviceID,
|
||||
"report_id", report.ReportID,
|
||||
"item_count", len(report.Items),
|
||||
)
|
||||
return nil
|
||||
case contracts.KindResponses:
|
||||
result, err := contracts.Decode[contracts.CommandResult](message.Payload, topic, receivedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate command result: %w", err)
|
||||
}
|
||||
// Section 7 adds pending-command and correlation matching.
|
||||
s.logger.Info("command result validated; matching not implemented",
|
||||
"device_id", result.DeviceID,
|
||||
"command_id", result.CommandID,
|
||||
"status", result.Status,
|
||||
)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("device-view service does not handle topic kind %q", topic.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Status(deviceID string) (Status, bool) {
|
||||
return s.store.GetStatus(deviceID, s.now().UTC(), s.freshFor)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package deviceview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"example.com/mqtt5-learning/internal/contracts"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
DeviceID string
|
||||
MessageID string
|
||||
ReportedAt time.Time
|
||||
ReceivedAt time.Time
|
||||
Temperature float64
|
||||
TemperatureSet bool
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Snapshot Snapshot
|
||||
HeartbeatFresh bool
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
latest map[string]Snapshot
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{latest: make(map[string]Snapshot)}
|
||||
}
|
||||
|
||||
func (s *Store) RecordHeartbeat(message contracts.Heartbeat, receivedAt time.Time) (bool, error) {
|
||||
reportedAt, err := time.Parse(time.RFC3339, message.OccurredAt)
|
||||
if err != nil {
|
||||
// Decode already validates this timestamp. Keep the store defensive too.
|
||||
return false, fmt.Errorf("parse heartbeat timestamp: %w", err)
|
||||
}
|
||||
|
||||
snapshot := Snapshot{
|
||||
DeviceID: message.DeviceID,
|
||||
MessageID: message.MessageID,
|
||||
ReportedAt: reportedAt,
|
||||
ReceivedAt: receivedAt,
|
||||
}
|
||||
if message.Temperature != nil {
|
||||
snapshot.Temperature = *message.Temperature
|
||||
snapshot.TemperatureSet = true
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
previous, exists := s.latest[message.DeviceID]
|
||||
if exists && !reportedAt.After(previous.ReportedAt) {
|
||||
return false, nil
|
||||
}
|
||||
s.latest[message.DeviceID] = snapshot
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetStatus(deviceID string, now time.Time, freshFor time.Duration) (Status, bool) {
|
||||
s.mu.RLock()
|
||||
snapshot, exists := s.latest[deviceID]
|
||||
s.mu.RUnlock()
|
||||
if !exists {
|
||||
return Status{}, false
|
||||
}
|
||||
|
||||
fresh := freshFor > 0 && !now.Before(snapshot.ReceivedAt) && now.Sub(snapshot.ReceivedAt) <= freshFor
|
||||
return Status{Snapshot: snapshot, HeartbeatFresh: fresh}, true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+24
-23
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"example.com/mqtt5-learning/internal/config"
|
||||
"example.com/mqtt5-learning/internal/contracts"
|
||||
"github.com/eclipse/paho.golang/autopaho"
|
||||
"github.com/eclipse/paho.golang/paho"
|
||||
)
|
||||
@@ -18,7 +19,7 @@ type Message struct {
|
||||
Retain bool
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, cfg config.Config, topics []string, inbox chan<- Message) (*autopaho.ConnectionManager, error) {
|
||||
func Start(ctx context.Context, cfg config.Config, topics []contracts.TopicFilter, inbox chan<- Message) (*autopaho.ConnectionManager, error) {
|
||||
broker, err := url.Parse(cfg.MQTT.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -38,7 +39,7 @@ func Start(ctx context.Context, cfg config.Config, topics []string, inbox chan<-
|
||||
|
||||
subscriptions := make([]paho.SubscribeOptions, 0, len(topics))
|
||||
for _, topic := range topics {
|
||||
subscriptions = append(subscriptions, paho.SubscribeOptions{Topic: topic, QoS: 1, RetainAsPublished: true})
|
||||
subscriptions = append(subscriptions, paho.SubscribeOptions{Topic: topic.String(), QoS: 1, RetainAsPublished: true})
|
||||
}
|
||||
|
||||
ack, subErr := cm.Subscribe(subCtx, &paho.Subscribe{Subscriptions: subscriptions})
|
||||
@@ -64,24 +65,24 @@ func Start(ctx context.Context, cfg config.Config, topics []string, inbox chan<-
|
||||
default:
|
||||
}
|
||||
},
|
||||
OnConnectionDown: func() bool { log.Print("disconnected; reconnecting"); return true },
|
||||
OnConnectError: func(err error) { log.Printf("connect error: %v", err) },
|
||||
ClientID: cfg.MQTT.ClientID,
|
||||
OnClientError: func(err error) { log.Printf("client error: %v", err) },
|
||||
OnServerDisconnect: func(d *paho.Disconnect) { log.Printf("disconnect reason=0x%02x", d.ReasonCode) },
|
||||
OnPublishReceived: []func(paho.PublishReceived) (bool, error){
|
||||
func(received paho.PublishReceived) (bool, error) {
|
||||
packet := received.Packet
|
||||
message := Message{Topic: packet.Topic, Payload: append([]byte(nil), packet.Payload...), Retain: packet.Retain}
|
||||
select {
|
||||
case inbox <- message:
|
||||
default:
|
||||
// Deliberately lossy early demo: automatic MQTT ACK is still possible.
|
||||
log.Printf("lab inbox full; message dropped topic=%s", packet.Topic)
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
OnConnectionDown: func() bool { log.Print("disconnected; reconnecting"); return true },
|
||||
OnConnectError: func(err error) { log.Printf("connect error: %v", err) },
|
||||
ClientID: cfg.MQTT.ClientID,
|
||||
OnClientError: func(err error) { log.Printf("client error: %v", err) },
|
||||
OnServerDisconnect: func(d *paho.Disconnect) { log.Printf("disconnect reason=0x%02x", d.ReasonCode) },
|
||||
OnPublishReceived: []func(paho.PublishReceived) (bool, error){
|
||||
func(received paho.PublishReceived) (bool, error) {
|
||||
packet := received.Packet
|
||||
message := Message{Topic: packet.Topic, Payload: append([]byte(nil), packet.Payload...), Retain: packet.Retain}
|
||||
select {
|
||||
case inbox <- message:
|
||||
default:
|
||||
// Deliberately lossy early demo: automatic MQTT ACK is still possible.
|
||||
log.Printf("lab inbox full; message dropped topic=%s", packet.Topic)
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start AutoPaho: %w", err)
|
||||
@@ -105,12 +106,12 @@ func Start(ctx context.Context, cfg config.Config, topics []string, inbox chan<-
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func Publish(ctx context.Context, manager *autopaho.ConnectionManager, topic string, payload []byte) error {
|
||||
func Publish(ctx context.Context, manager *autopaho.ConnectionManager, topic contracts.TopicName, payload []byte) error {
|
||||
operation, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ack, err := manager.Publish(operation, &paho.Publish{
|
||||
Topic: topic,
|
||||
Topic: topic.String(),
|
||||
Payload: payload,
|
||||
QoS: 1,
|
||||
Retain: false,
|
||||
@@ -137,7 +138,7 @@ func Stop(manager *autopaho.ConnectionManager) {
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err:=manager.Disconnect(shutdown); err != nil {
|
||||
if err := manager.Disconnect(shutdown); err != nil {
|
||||
log.Printf("shutdown did not finish within deadline: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user