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.

๐ŸŽฏ One skill: define workflow evidence๐Ÿง  Predict duplicate and stale effects๐Ÿงช Synthetic data only

โ“ 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

Backend
desired revision 9
โ†’
Broker
PUBACK for QoS hop
โ†’
Simulator
validate + apply
โ†’
Backend
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.

๐Ÿง  Check the evidence: What proves the device applied catalog revision 9?




โš™๏ธ 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

  1. Replay one report ID twice and then reuse it with changed content. Define whether changed content is a conflict.
  2. Apply catalog revision 9, then 8, then a duplicate 9. Record the simulator's reported state and deduplication effect.
  3. Request config at revision 4 while the backend is preparing revision 5. Define which response is valid.
  4. Compare a fresh heartbeat with a device health report that says โ€œfaultโ€. Explain why freshness is not health.
  5. 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
  1. Use stable application report identity, not MQTT packet ID.
  2. Device-reported applied revision is application evidence; PUBACK is transport evidence.
  3. 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.