Files
mqtt-5-learning/cmd/simulator/main.go
T
Ryan fb30e8e884 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.
2026-10-22 10:19:56 +03:30

145 lines
4.6 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
"example.com/mqtt5-learning/internal/config"
"example.com/mqtt5-learning/internal/contracts"
"example.com/mqtt5-learning/internal/dispatch"
mqtt "example.com/mqtt5-learning/internal/mqtt"
"github.com/eclipse/paho.golang/autopaho"
)
func main() {
// Convert a returned application error into a visible nonzero process exit.
if err := run(); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run() error {
// Load settings and bind cancellation to Ctrl+C or the container stop signal.
cfg, err := config.Load(os.Args[1:])
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
transportCtx, stopTransport := context.WithCancel(context.Background())
defer stopTransport()
// Build concrete names for this device and a filter for its command flow.
commandFilter, err := contracts.DeviceSubscription(cfg.MQTT.DeviceID, contracts.KindCommands)
if err != nil {
return err
}
telemetryTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindTelemetry)
if err != nil {
return err
}
inbox := make(chan mqtt.Message, 16)
manager, err := mqtt.Start(transportCtx, cfg, []contracts.TopicFilter{commandFilter}, inbox)
if err != nil {
return err
}
defer mqtt.Stop(manager)
// Keep worker contexts alive during the drain; the signal context stops intake.
runID := time.Now().UnixNano()
telemetryPool := dispatch.New(context.Background(), 1, 1)
importantPool := dispatch.New(context.Background(), 2, 16)
var resultSequence atomic.Uint64
receiver := &dispatch.Receiver{
Telemetry: telemetryPool,
Important: importantPool,
Process: func(jobCtx context.Context, message mqtt.Message) error {
resultID := fmt.Sprintf("result-%d-%d", runID, resultSequence.Add(1))
return respond(jobCtx, manager, cfg.MQTT.DeviceID, resultID, message)
},
}
defer func() {
drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
started := time.Now()
if err := dispatch.Shutdown(drainCtx, telemetryPool, importantPool); err != nil {
log.Printf("dispatcher shutdown incomplete: %v", err)
}
log.Printf("dispatcher drain finished elapsed=%s telemetry=%+v important=%+v",
time.Since(started), telemetryPool.Stats(), importantPool.Stats())
}()
// Heartbeats are periodic; command handling remains active between ticks.
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
var heartbeatSequence uint64
for {
// Handle cancellation, telemetry publication, and inbound commands together.
select {
case <-ctx.Done():
log.Print("shutting down; draining accepted command work before MQTT disconnect")
return nil
case <-ticker.C:
heartbeatSequence++
now := time.Now().UTC()
payload, err := json.Marshal(contracts.Heartbeat{
SchemaVersion: contracts.SchemaVersionOne,
MessageID: fmt.Sprintf("heartbeat-%d-%d", runID, heartbeatSequence),
DeviceID: cfg.MQTT.DeviceID,
OccurredAt: now.Format(time.RFC3339),
})
if err != nil {
return err
}
if err = mqtt.Publish(ctx, manager, telemetryTopic, payload); err != nil {
log.Printf("heartbeat: %v", err)
}
case message := <-inbox:
outcome, submitErr := receiver.Submit(ctx, message)
if submitErr != nil || outcome != dispatch.Accepted {
log.Printf("command admission rejected outcome=%d topic=%q error=%v", outcome, message.Topic, submitErr)
}
}
}
}
func respond(ctx context.Context, manager *autopaho.ConnectionManager, deviceID, resultMessageID string, message mqtt.Message) error {
if message.Retain {
return fmt.Errorf("retained command rejected")
}
topic, err := contracts.ParseTopic(message.Topic)
if err != nil {
return err
}
if topic.Kind != contracts.KindCommands || topic.DeviceID != deviceID {
return fmt.Errorf("command topic is not authorized for this device")
}
command, err := contracts.Decode[contracts.Command](message.Payload, topic, time.Now().UTC())
if err != nil {
return err
}
response, err := json.Marshal(contracts.CommandResult{
SchemaVersion: contracts.SchemaVersionOne,
MessageID: resultMessageID,
DeviceID: deviceID,
OccurredAt: time.Now().UTC().Format(time.RFC3339),
CommandID: command.CommandID,
Status: "completed",
Detail: "pong",
})
if err != nil {
return fmt.Errorf("encode command result: %w", err)
}
responseTopic, err := contracts.DeviceTopic(deviceID, contracts.KindResponses)
if err != nil {
return fmt.Errorf("build response topic: %w", err)
}
return mqtt.Publish(ctx, manager, responseTopic, response)
}