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.
145 lines
4.6 KiB
Go
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)
|
|
}
|