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
+16 -2
View File
@@ -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
}
+17
View File
@@ -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")
}
}
+1 -1
View File
@@ -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")
}
+4 -46
View File
@@ -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)
}
}
+103
View File
@@ -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
View File
@@ -1 +0,0 @@
package contracts
+96
View File
@@ -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)
}
+72
View File
@@ -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
}
+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
}
+24 -23
View File
@@ -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)
}
}
}