Go MQTT Vending Backend ยท Lesson 12 ยท 12 minutes
Connect MQTT patterns to vending workflows
Apply stable business identities and explicit application evidence to sales, catalog, configuration, health, and device logs.
โ Start with the system story
A simulator sends the same sale report twice, receives a new product catalog, and later asks for configuration. Which side owns each fact, and what proves the device applied the catalog?
Pause: separate backend business records from device-reported state.
๐ฐ Sales: stable identity survives retries
Deduplicate with a business key such as (device_id, report_id). MQTT packet identifiers belong to protocol exchanges and can be reused; they are not sale IDs. In this lesson's in-memory stage, replay the same report without repeating its business effect, and state clearly that a backend restart forgets the deduplication map.
๐ฆ Catalog: send intent, receive application evidence
desired revision 9
PUBACK for QoS hop
validate + apply
reported revision 9
Include schema version, stable message/job ID, and monotonic revision. Have the simulator reject revision 8 after applying revision 9. If you model catalog as replaceable desired state, retained state may fit; if each delivery is a one-time job, publish it nonretained. Pick one semantic model and explain it.
โ๏ธ Configuration: make races visible
A device fetch request should carry a request ID and its known revision. The backend returns the selected version to an authorized response destination; the device validates the request/revision and applies only according to the agreed revision rule. Correlation Data can match a response to a request, but it does not authorize a Response Topic.
โค๏ธ Health and logs answer different questions
Track broker connection status, heartbeat freshness, and device-reported health as separate observations. Bound log size and frequency, sanitize untrusted content, and avoid dumping raw payloads. Decide which records are disposable telemetry and which deserve durable storage in Lesson 13.
๐งญ File overview: internal/vending/store.go
Create this complete file for the Lesson 12 in-memory stage. It owns accepted synthetic sales identities and the latest catalog revision per device. It deliberately does not publish MQTT messages; the existing transport and Section 7 command path remain responsible for wire delivery.
๐ฐ RecordSale โ short summary
Stores a validated report under (device_id, report_id) and returns whether the effect was new, a duplicate, or an ID conflict.
๐ฆ ApplyCatalog โ short summary
Applies only a newer catalog revision and returns a copy of its product list.
๐งฉ Complete file contents
package vending
import (
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"example.com/mqtt5-learning/internal/contracts"
)
type SaleOutcome uint8
const (
SaleAccepted SaleOutcome = iota + 1
SaleDuplicate
SaleIDConflict
)
type CatalogItem struct {
SKU string `json:"sku"`
Price int64 `json:"price_minor"`
}
type Catalog struct {
SchemaVersion int `json:"schema_version"`
JobID string `json:"job_id"`
DeviceID string `json:"device_id"`
Revision uint64 `json:"revision"`
Items []CatalogItem `json:"items"`
}
type Configuration struct {
SchemaVersion int `json:"schema_version"`
RequestID string `json:"request_id"`
DeviceID string `json:"device_id"`
Revision uint64 `json:"revision"`
Settings map[string]string `json:"settings"`
}
type HealthObservation struct {
DeviceID string
Status string
ReceivedAt time.Time
}
type Store struct {
mu sync.Mutex
sales map[string][32]byte
effects uint64
catalogs map[string]Catalog
configs map[string]Configuration
health map[string]HealthObservation
}
func NewStore() *Store {
return &Store{
sales: make(map[string][32]byte), catalogs: make(map[string]Catalog),
configs: make(map[string]Configuration), health: make(map[string]HealthObservation),
}
}
// RecordSale uses stable business identity; MQTT packet IDs are not accepted here.
func (s *Store) RecordSale(report contracts.SalesReport) (SaleOutcome, error) {
if report.DeviceID == "" || report.ReportID == "" {
return 0, fmt.Errorf("device ID and report ID are required")
}
encoded, err := json.Marshal(report)
if err != nil {
return 0, fmt.Errorf("encode report identity: %w", err)
}
digest := sha256.Sum256(encoded)
key := report.DeviceID + "/" + report.ReportID
s.mu.Lock()
defer s.mu.Unlock()
if previous, exists := s.sales[key]; exists {
if previous != digest {
return SaleIDConflict, nil
}
return SaleDuplicate, nil
}
s.sales[key] = digest
s.effects++ // Stand-in for one synthetic business effect in this lesson.
return SaleAccepted, nil
}
func (s *Store) EffectCount() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.effects
}
// ApplyCatalog replaces device state only for a strictly newer revision.
func (s *Store) ApplyCatalog(catalog Catalog) (bool, error) {
if catalog.DeviceID == "" || catalog.JobID == "" || catalog.Revision == 0 {
return false, fmt.Errorf("device ID, job ID, and positive revision are required")
}
for _, item := range catalog.Items {
if item.SKU == "" || item.Price < 0 {
return false, fmt.Errorf("catalog item has invalid SKU or price")
}
}
s.mu.Lock()
defer s.mu.Unlock()
previous, exists := s.catalogs[catalog.DeviceID]
if exists && catalog.Revision <= previous.Revision {
return false, nil
}
catalog.Items = append([]CatalogItem(nil), catalog.Items...)
s.catalogs[catalog.DeviceID] = catalog
return true, nil
}
func (s *Store) CatalogFor(deviceID string) (Catalog, bool) {
s.mu.Lock()
defer s.mu.Unlock()
catalog, exists := s.catalogs[deviceID]
if !exists {
return Catalog{}, false
}
catalog.Items = append([]CatalogItem(nil), catalog.Items...)
return catalog, true
}
// ApplyConfiguration rejects equal or older revisions to make retries idempotent.
func (s *Store) ApplyConfiguration(config Configuration) (bool, error) {
if config.DeviceID == "" || config.RequestID == "" || config.Revision == 0 || len(config.Settings) == 0 {
return false, fmt.Errorf("device, request ID, positive revision, and settings are required")
}
for key, value := range config.Settings {
if key == "" || len(key) > 64 || len(value) > 256 || strings.ContainsAny(key, "\r\n") {
return false, fmt.Errorf("configuration contains an invalid key or oversized value")
}
}
s.mu.Lock()
defer s.mu.Unlock()
previous, exists := s.configs[config.DeviceID]
if exists && config.Revision <= previous.Revision {
return false, nil
}
config.Settings = cloneSettings(config.Settings)
s.configs[config.DeviceID] = config
return true, nil
}
func (s *Store) ConfigurationFor(deviceID string) (Configuration, bool) {
s.mu.Lock()
defer s.mu.Unlock()
config, exists := s.configs[deviceID]
if !exists { return Configuration{}, false }
config.Settings = cloneSettings(config.Settings)
return config, true
}
func (s *Store) RecordHealth(deviceID, status string, receivedAt time.Time) error {
if deviceID == "" || (status != "ok" && status != "warning" && status != "fault") {
return fmt.Errorf("device ID or health status is invalid")
}
s.mu.Lock()
s.health[deviceID] = HealthObservation{DeviceID: deviceID, Status: status, ReceivedAt: receivedAt.UTC()}
s.mu.Unlock()
return nil
}
func (s *Store) HealthFor(deviceID string) (HealthObservation, bool) {
s.mu.Lock()
defer s.mu.Unlock()
observation, ok := s.health[deviceID]
return observation, ok
}
// SanitizeLogLine bounds untrusted device log text before it reaches a log sink.
func SanitizeLogLine(message string) string {
clean := strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' || r == 0 { return ' ' }
if r < 0x20 && r != '\t' { return -1 }
return r
}, message)
clean = strings.TrimSpace(clean)
if len(clean) > 512 { clean = clean[:512] }
return clean
}
func cloneSettings(input map[string]string) map[string]string {
copy := make(map[string]string, len(input))
for key, value := range input { copy[key] = value }
return copy
}
๐ Details: what this file proves
The SHA-256 digest makes a reused report ID with changed content visible as a conflict. The mutex makes state safe inside this process, but it is not durable across restart and not shared between processes. EffectCount is a lab observation, not a revenue ledger. Catalog slices and configuration maps are copied on write/read so callers cannot mutate stored state through shared backing data. SanitizeLogLine removes line breaks/control bytes and caps log text; it does not identify secrets, so never log arbitrary payloads without an allowlist.
๐งช Build the workflow matrix
- Replay one report ID twice and then reuse it with changed content. Define whether changed content is a conflict.
- Apply catalog revision 9, then 8, then a duplicate 9. Record the simulator's reported state and deduplication effect.
- Request config at revision 4 while the backend is preparing revision 5. Define which response is valid.
- Compare a fresh heartbeat with a device health report that says โfaultโ. Explain why freshness is not health.
- Inspect the existing backend read-only and record its actual contract/identity behavior without assuming lab names match production.
๐ What each result proves
Two MQTT deliveries prove the broker delivered twice; one in-memory effect shows this process deduplicated. A backend PUBACK proves its QoS exchange. A device result/report proves application-level evidence it chose to report. None of these in-memory exercises prove crash-safe deduplication; PostgreSQL arrives next.
โ Done when
Duplicates do not repeat the simulated business effect, stale revisions do not replace newer state, and your workflow notes name the evidence boundary for each operation.
๐ Short self-check answers
- Use stable application report identity, not MQTT packet ID.
- Device-reported applied revision is application evidence; PUBACK is transport evidence.
- Fresh heartbeat, current MQTT connection, and device health measure different facts.
๐ Full workflow exercise and comparison questions: Lesson 12 source lesson. Primary reference: OASIS MQTT 5.0 Standard for the transport acknowledgement boundary.
Ask me to review a duplicate-report or stale-revision result with you before moving on to PostgreSQL.