Files
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.4 KiB
Go

package mqtt
import (
"context"
"fmt"
"log"
"net/url"
"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"
)
type Message struct {
Topic string
Payload []byte
Retain bool
}
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
}
initialSubscription := make(chan error, 1)
manager, err := autopaho.NewConnection(ctx, autopaho.ClientConfig{
ServerUrls: []*url.URL{broker},
KeepAlive: 20,
CleanStartOnInitialConnection: true,
SessionExpiryInterval: 0,
ConnectUsername: cfg.MQTT.Username,
ConnectPassword: []byte(cfg.MQTT.Password),
OnConnectionUp: func(cm *autopaho.ConnectionManager, _ *paho.Connack) {
// This runs again on reconnect; reestablish broker subscriptions.
subCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
subscriptions := make([]paho.SubscribeOptions, 0, len(topics))
for _, topic := range topics {
subscriptions = append(subscriptions, paho.SubscribeOptions{Topic: topic.String(), QoS: 1, RetainAsPublished: true})
}
ack, subErr := cm.Subscribe(subCtx, &paho.Subscribe{Subscriptions: subscriptions})
if subErr == nil {
if ack == nil || len(ack.Reasons) != len(topics) {
subErr = fmt.Errorf("missing or incomplete SUBACK")
} else {
for i, reason := range ack.Reasons {
if reason > 1 { // We requested QoS 1; valid grants are 0 or 1.
subErr = fmt.Errorf("subscription %s rejected: 0x%02x", topics[i], reason)
break
}
}
}
}
if subErr != nil {
log.Printf("subscribe failed: %v", subErr)
} else {
log.Printf("subscriptions ready: %v", topics)
}
select {
case initialSubscription <- subErr:
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
},
},
})
if err != nil {
return nil, fmt.Errorf("start AutoPaho: %w", err)
}
startup, cancel := context.WithTimeout(ctx, cfg.ConnectTimeout())
defer cancel()
if err = manager.AwaitConnection(startup); err != nil {
Stop(manager)
return nil, fmt.Errorf("await connection: %w", err)
}
select {
case err = <-initialSubscription:
if err != nil {
Stop(manager)
return nil, err
}
case <-startup.Done():
Stop(manager)
return nil, fmt.Errorf("await subscriptions: %w", startup.Err())
}
return manager, nil
}
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.String(),
Payload: payload,
QoS: 1,
Retain: false,
Properties: &paho.PublishProperties{
ContentType: "application/json",
},
})
if err != nil {
return fmt.Errorf("publish: %w", err)
}
if ack == nil {
return fmt.Errorf("missing Qos 1 publication response")
}
if ack.ReasonCode >= 0x80 {
return fmt.Errorf("publish rejected: 0x%02x", ack.ReasonCode)
}
log.Printf("broker publish acknowledgement topic=%s reason=0x%02x", topic, ack.ReasonCode)
return nil
}
func Stop(manager *autopaho.ConnectionManager) {
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := manager.Disconnect(shutdown); err != nil {
log.Printf("shutdown did not finish within deadline: %v", err)
}
}