diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5ab3004 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +## Agent skills + +### Issue tracker + +Issues are tracked in `.scratch/`; Gitea Issues may also be used alongside it. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Use the default labels: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, and `wontfix`. See `docs/agents/triage-labels.md`. + +### Domain docs + +Use the single-context layout. See `docs/agents/domain.md`. diff --git a/COURSE.md b/COURSE.md index cea5ce4..688d37b 100644 --- a/COURSE.md +++ b/COURSE.md @@ -2,13 +2,13 @@ ## Starting point and destination -You already understand MQTT 3.1.1 concepts. This course concentrates on writing, debugging, testing, and maintaining Go services that communicate with IoT devices. Go and basic Docker syntax are assumed. Explain MQTT semantics where code depends on them, including packet flows, guarantees, failure boundaries, and the purpose and effect of MQTT or advanced Docker commands. Each experiment starts with a prediction and ends with an explanation, not merely successful copied output. +You have four years of Go backend experience and are confident with Go syntax and core language concepts. You also understand MQTT 3.1.1. Treat both as established skills: do not spend lesson time teaching Go basics or introductory MQTT. Focus on designing, debugging, testing, and operating Go services that communicate with IoT devices, including the MQTT 5 differences and the places where protocol behavior affects application guarantees. Explain Go details only when they materially affect a design decision, such as ownership, concurrency, cancellation, error handling, or API shape. Explain packet flows, failure boundaries, and the purpose and effect of MQTT or advanced Docker commands. Each experiment starts with a prediction and ends with an explanation, not merely successful copied output. You will build a small vending backend and a Go device simulator, then use that experience to understand the MQTT 5 backend delivered to you. Work through the numbered sections in order. Each checkbox is an exercise you implement; the tutor provides explanations, small examples, debugging help, and code review. A section can take several sessions. There is no need to finish a whole section in one sitting. This is the requested course and implementation checklist. It does not require another round of sprint planning. Ask the tutor to start a numbered exercise when ready; full solutions should wait until you have attempted it or requested one. -Start with [Lesson 0 — The broker, Go backend, and device lifecycle](lessons/00-system-lifecycle.md) for the end-to-end story. Lessons 1–8 now begin with the system story and a guiding question before each implementation step. +Start with [Lesson 0 — The broker, Go backend, and device lifecycle](teaching/course-source/00-system-lifecycle.md) for the end-to-end story. Lessons 1–8 now begin with the system story and a guiding question before each implementation step. All implementation belongs in `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Use a dedicated local broker, synthetic data, lab credentials, and simulated devices. Reference the existing backend read-only. @@ -42,6 +42,8 @@ The future delivered backend must be inspected again. Do not assume it will use ## Course rules and working structure - Build one evolving application, adding only the structure needed by the current section. +- Assume fluency with Go syntax, pointers, interfaces, channels, standard-library patterns, and ordinary testing. Spend explanation time on system boundaries, concurrency/lifecycle decisions, tradeoffs, failure modes, and evidence; revisit a Go concept only when its precise behavior affects the exercise. +- Keep implementation exercises substantive: ask the learner to make and defend a design choice, implement the relevant slice, and explain what the observed result proves and does not prove. Do not replace implementation practice with a conceptual overview. - Start with `cmd/backend` and `cmd/simulator`; introduce API and ingestion executables in Section 15. - Add `internal/config`, `internal/mqtt`, `internal/contracts`, and domain packages as they become useful. Keep interfaces small and driven by callers. - Keep upstream code under `references/paho.golang/` unchanged. Read `autopaho/examples/basics` first; use `backoff`, `queue`, `router`, and `rpc` when their lessons arrive. Lower-level examples also live under `paho/cmd/`. @@ -53,7 +55,7 @@ The future delivered backend must be inspected again. Do not assume it will use ## Section 1 — Prepare an isolated, reproducible lab -Detailed lesson: [Section 1 instructions and examples](lessons/01-lab-setup.md). +Detailed lesson: [Section 1 instructions and examples](teaching/course-source/01-lab-setup.md). Start with the broker's role and setup: which configuration belongs to Mosquitto, how client accounts are supplied, and what local CLI evidence confirms the broker is ready for Go clients. @@ -74,7 +76,7 @@ Start with the broker's role and setup: which configuration belongs to Mosquitto ## Section 2 — Write your first Go MQTT 5 connection -Detailed lesson: [Section 2 connection lifecycle and code](lessons/02-go-connection.md). +Detailed lesson: [Section 2 connection lifecycle and code](teaching/course-source/02-go-connection.md). Follow the backend from configuration through AutoPaho, Paho, CONNECT, and CONNACK. The story question is what proves connection acceptance and how startup differs from a running process. @@ -92,7 +94,7 @@ Follow the backend from configuration through AutoPaho, Paho, CONNECT, and CONNA ## Section 3 — Add typed configuration -Detailed lesson: [Section 3 TOML and koanf implementation](lessons/03-typed-configuration.md). +Detailed lesson: [Section 3 TOML and koanf implementation](teaching/course-source/03-typed-configuration.md). Trace how each Go process gets its own identity and credentials, how configuration sources merge, and when validation must finish before MQTT startup. @@ -108,51 +110,67 @@ Trace how each Go process gets its own identity and credentials, how configurati ## Section 4 — Publish, subscribe, and simulate a device -Detailed lesson: [Section 4 Paho transport, backend, and simulator](lessons/04-publish-subscribe-simulator.md). +Detailed lesson: [Section 4 Paho transport, backend, and simulator](teaching/course-source/04-publish-subscribe-simulator.md). Start with the story of how the backend and simulator connect, subscribe, exchange a command, and report a device-level response before building the transport helper. **Outcome:** Two Go processes exchange messages through the broker. 1. [ ] Create a simulator with its own identity and client ID. Give the backend and simulator different topic permissions. -2. [ ] Define a lab namespace, for example `lab/v1/devices/{deviceID}/...`, with separate telemetry, commands, and responses topics. These are lab contracts, not changes to existing production topics. +2. [ ] Define the lab hierarchy `lab/v1/devices/{deviceID}/{kind}` in `internal/contracts/topic.go`. Use distinct Go types/builders for concrete publish names, fleet-wide filters, and exact-device filters; keep role-specific subscription choices at process wiring and broker authorization in Mosquitto ACLs. These are lab contracts, not changes to existing production topics. 3. [ ] Subscribe, inspect SUBACK results, and wait for subscription readiness before publishing a message that must be received. -4. [ ] Publish at QoS 0 and 1. Inspect errors and acknowledgements rather than assuming a nil transport error proves the device acted. +4. [ ] Compare QoS 0, 1, and 2 for a lab publication. Trace the QoS 2 handshake and compare its protocol guarantee with application deduplication and business completion; inspect errors and acknowledgements rather than assuming a nil transport error proves the device acted. 5. [ ] Send heartbeats from the simulator; receive a harmless command and return a simple response. **Done when:** You can trace CONNECT, SUBSCRIBE/SUBACK, PUBLISH, and QoS 1 PUBACK for both directions. Explain why a PUBACK is not a business response and why QoS is evaluated per delivery hop. ## Section 5 — Design contracts and validate inbound messages -Detailed lesson: [Section 5 instructions](lessons/05-contracts-validation.md). +Detailed lesson: [Section 5 instructions](teaching/course-source/05-contracts-validation.md). **Outcome:** A malformed or misleading message cannot corrupt application state. 1. [ ] Define versioned JSON structures for heartbeat, command, command result, and synthetic sales report. Include device identity, stable message ID, and relevant timestamps. -2. [ ] Validate topic shape, topic/body identity agreement, payload size, schema version, required values, and timestamps. Decide explicitly how unknown fields and unsupported versions behave. +2. [ ] Use `contracts.ParseTopic` from `topic.go`, then validate topic/body identity agreement, payload size, schema version, required values, and timestamps. Decide explicitly how unknown fields and unsupported versions behave. 3. [ ] Separate topic parsing and decoding from business handlers. Introduce Content Type and Payload Format Indicator as metadata; still validate the actual payload. 4. [ ] Add table-driven tests for malformed JSON, identity mismatch, unknown commands, missing IDs, and boundary sizes. 5. [ ] Return safe application errors without echoing confidential content. **Done when:** Valid messages route correctly; invalid messages have consistent outcomes. Explain why a body field claiming a device ID does not authenticate the sender. +## Section 5A — Turn validated messages into backend state + +Detailed lesson: [Section 5A — Backend message processing](teaching/course-source/05a-backend-message-processing.md). + +This is the missing application step between the first five lessons and concurrency. Section 5 proves a message is structurally valid; this section routes it to a use case, updates a small in-memory device view from heartbeats, and records safe structured outcomes. It deliberately does not implement durable sales storage, full device-log ingestion, or vending configuration workflows. + +**Outcome:** You can explain and implement the path from a valid MQTT message to an application state change, and distinguish a recent heartbeat from a live broker connection or full device-health result. + +1. [ ] Separate transport receipt, contract validation, application service, state store, and application logging. +2. [ ] Store the newest heartbeat observation per device; compare device report time for ordering and backend receive time for freshness. +3. [ ] Expose a status query that reports whether a heartbeat arrived within a configured window without claiming the device is definitely offline. +4. [ ] Log stable identifiers and processing outcomes without dumping arbitrary payloads. +5. [ ] State the RAM-loss boundary; keep sales persistence, command correlation, and diagnostic-log workflows for their later sections. + +**Done when:** A valid heartbeat updates the device view; malformed and older messages do not replace it; you can explain why MQTT PUBACK, heartbeat freshness, device health, and business completion are separate evidence. + ## Section 6 — Manage callbacks, concurrency, and cancellation -Detailed lesson: [Section 6 instructions](lessons/06-concurrency-cancellation.md). +Detailed lesson: [Section 6 instructions](teaching/course-source/06-concurrency-cancellation.md). **Outcome:** Message handling remains responsive under concurrent traffic. 1. [ ] Inspect the selected Paho release's callback execution and acknowledgement behavior. Document which work happens before and after callback return. -2. [ ] Add bounded work queues and a small worker pool. Define an overload policy separately for disposable telemetry and important commands/reports. -3. [ ] Protect shared state with a deliberate ownership or locking model. Copy message data before retaining it if required by the API's ownership contract. +2. [ ] Move Section 5A's application service behind bounded work queues and a small worker pool. Define an overload policy separately for telemetry and important commands/reports. +3. [ ] Protect the shared device view with a deliberate ownership or locking model. Copy message data before retaining it if required by the API's ownership contract. 4. [ ] Propagate lifecycle contexts and operation deadlines; avoid unbounded goroutines and per-message background contexts. -5. [ ] Add a slow handler and a burst generator; observe queue saturation and shutdown while work is pending. +5. [ ] Add a slow handler and a burst generator; observe queue saturation and shutdown while work is pending. Record queue depth, processing latency, and dropped or rejected work so overload is visible before the operations section. **Done when:** The learning module passes `go test -race ./...`, and overload behavior is observable and bounded. Explain the possible loss window if MQTT acknowledges a message before queued application work is durable. ## Section 7 — Build reliable command/response behavior -Detailed lesson: [Section 7 explanations and experiments](lessons/07-command-response.md). +Detailed lesson: [Section 7 explanations and experiments](teaching/course-source/07-command-response.md). **Outcome:** Concurrent commands are matched to the correct device responses. @@ -164,14 +182,18 @@ Detailed lesson: [Section 7 explanations and experiments](lessons/07-command-res **Done when:** Many concurrent commands receive the right results; duplicate commands produce one simulated effect; late responses follow a documented policy. Explain why MQTT Correlation Data alone supplies neither authorization nor deduplication. +**State boundary:** Command waiters and command state are in memory at this stage. A process restart can lose them; an HTTP/API acceptance here is not durable acceptance. Sections 13–14 add persisted command state and recoverable publication intent. + **Read:** AutoPaho's `rpc` example as a mechanism reference, then compare its scope with your application state machine. ## Section 8 — Expose device commands through HTTP -Detailed lesson: [Section 8 instructions](lessons/08-http-commands.md). +Detailed lesson: [Section 8 instructions](teaching/course-source/08-http-commands.md). **Outcome:** An API caller can create a command and query its status. +This endpoint is a local lab exercise. Keep it bound to the lab environment and treat it as unauthenticated until Section 11 adds API authentication and per-device authorization. Command state is still in memory, so do not describe an accepted request as durable until Sections 13–14. + 1. [ ] Use `net/http` for `POST /devices/{id}/commands` and `GET /commands/{id}`. Return a command ID and asynchronous acceptance when appropriate. 2. [ ] Separate HTTP decoding, command service, and MQTT transport. Keep command processing independent of a disconnected HTTP caller after acceptance. 3. [ ] Add request-body limits, JSON error responses, server timeouts, and controlled shutdown. @@ -182,12 +204,12 @@ Detailed lesson: [Section 8 instructions](lessons/08-http-commands.md). ## Section 9 — Learn MQTT 5 sessions through failure experiments -Detailed lesson: [Section 9 instructions](lessons/09-sessions-recovery.md). +Detailed lesson: [Section 9 instructions](teaching/course-source/09-sessions-recovery.md). **Outcome:** You can predict and explain reconnect and offline delivery behavior. 1. [ ] Compare stable and changing client IDs, Clean Start choices, zero/nonzero Session Expiry Interval, and the broker's Session Present result. -2. [ ] Disconnect the simulator, publish a QoS 1 command, and reconnect before and after expiry. Record the subscription and session prerequisites for queued delivery. +2. [ ] Disconnect the simulator, publish a QoS 1 command, and reconnect before and after expiry. Record the subscription and session prerequisites for queued delivery. Compare this queued one-shot command with a retained publication, which represents the latest retained value rather than an offline event history. 3. [ ] Test subscription restoration when a session exists and when it does not. Observe reconnect callbacks and avoid accidental repeated local handler registration. 4. [ ] Repeat with broker restart, first with persistence disabled and then configured. Record the limits of the actual broker persistence behavior. 5. [ ] Compare client session storage, the outgoing publication queue, broker session state, and your application state in a four-column recovery table. @@ -196,7 +218,7 @@ Detailed lesson: [Section 9 instructions](lessons/09-sessions-recovery.md). ## Section 10 — Handle expiry, presence, and desired state -Detailed lesson: [Section 10 explanations and experiments](lessons/10-expiry-presence-state.md). +Detailed lesson: [Section 10 explanations and experiments](teaching/course-source/10-expiry-presence-state.md). **Outcome:** Stale work and stale device status are handled deliberately. @@ -209,7 +231,7 @@ Detailed lesson: [Section 10 explanations and experiments](lessons/10-expiry-pre ## Section 11 — Secure device communication and API access -Detailed lesson: [Section 11 explanations and experiments](lessons/11-security-boundaries.md). +Detailed lesson: [Section 11 explanations and experiments](teaching/course-source/11-security-boundaries.md). **Outcome:** Unauthorized clients cannot control another device or observe its private traffic. @@ -223,7 +245,7 @@ Detailed lesson: [Section 11 explanations and experiments](lessons/11-security-b ### Optional Section 11B — Provision device ACLs with Mosquitto Dynamic Security -Detailed lesson: [Section 11B Dynamic Security implementation](lessons/11b-dynamic-security.md). +Detailed lesson: [Section 11B Dynamic Security implementation](teaching/course-source/11b-dynamic-security.md). Use this extension when you want to replace the lab's static password/ACL files with live per-device provisioning. It uses a separate broker configuration and state file so the working Section 1 lab remains intact. @@ -236,6 +258,8 @@ Use this extension when you want to replace the lab's static password/ACL files ## Section 12 — Implement representative vending workflows +Detailed lesson: [Lesson 12 — Representative vending workflows](teaching/course-source/12-vending-workflows.md). + **Outcome:** You understand the backend's main device-facing jobs using synthetic data. 1. [ ] Add synthetic sales reports and application responses keyed by report ID. Deduplicate in memory and identify the restart limitation. @@ -248,37 +272,11 @@ Use this extension when you want to replace the lab's static password/ACL files ## Section 13 — Persist reports and command state in PostgreSQL +Detailed lesson: [Lesson 13 — Persist reports and command state](teaching/course-source/13-persist-reports-command-state.md). + **Outcome:** Important state and deduplication survive application restart. -### 📥 Choose the acknowledgement boundary for important work - -The MQTT client receives the broker's QoS 1 PUBLISH and invokes the registered callback. In automatic acknowledgement mode, Paho sends PUBACK after the callback returns—even if the callback returns an error. Returning an error does not ask the broker to retry. A callback that places a message in a RAM queue and returns can therefore acknowledge it before a worker handles it; a process crash can then lose work the broker considers delivered. - -With Paho's manual acknowledgement enabled, the application calls `received.Client.Ack(received.Packet)` when its chosen acceptance boundary has been reached. For important reports, make that boundary a successful database transaction: validate the topic and payload, then atomically save the message/deduplication key and the business effect, and only then acknowledge. Keep the original client and packet available to the worker until `Ack` succeeds. Manual acknowledgement delays protocol completion; it does not make an in-memory queue durable. - -> **❓ Question:** **If the database is unavailable, should an important report be acknowledged?** -> -> **💬 Answer:** Not before the application has safely recorded it. Leave it unacknowledged and follow a deliberate connection/recovery policy, or reject/quarantine it durably if it is permanently invalid. Returning a callback error alone neither acknowledges nor requests redelivery in manual mode. Avoid leaving poison messages unacknowledged forever: they consume in-flight capacity and can stall later QoS 1/2 deliveries. - -### 🔁 Make database effects idempotent - -QoS 1 can deliver a message again when a connection fails before the broker receives PUBACK. Application retries and crash recovery can also repeat work. Use a stable application-level identity—such as `(device_id, report_id)`—and enforce it with a database unique constraint. In one transaction, decide whether this is new work, record the deduplication key, and apply the business effect. A duplicate should return or reproduce the prior application result without applying the effect twice. MQTT packet identifiers are temporary protocol identifiers, so they are not suitable business deduplication keys. - -For sales reports, commit the report record and its inventory/revenue effect atomically. For command results, commit the validated result and state transition together. A stable ID helps the application recognize repeats; the database constraint makes that decision safe across concurrent workers and process restarts. - -### 🧹 Give invalid messages a durable disposition - -Topic parsing and payload validation happen before business state changes. If a message is permanently invalid, record a bounded rejection reason and enough safe identity/context to diagnose it, or move it to a durable quarantine. After that disposition commits, acknowledge it so it does not block the stream. Do not log credentials or dump untrusted payloads. If failure is transient—for example, the database is temporarily unavailable—do not mark the message permanently rejected; keep it recoverable and apply a bounded retry/reconnect policy. - -### ⚖️ Match QoS and queue policy to the data - -QoS 1 is a practical choice for important ingestion when the application is built to tolerate duplicates. QoS 2 adds a protocol handshake; it does not make a database transaction or physical device effect exactly once. For disposable telemetry, automatic acknowledgement and bounded best-effort processing can be acceptable when drops are counted and freshness matters more than replay. For reports and command state, prefer durable admission and idempotent processing. - -Keep the callback short: copy the needed message data and submit it to a bounded worker queue. For important work, preserve the acknowledgement handle with the queued item and acknowledge only after the durable transaction commits. Bound queue capacity and configure MQTT 5 Receive Maximum; monitor queue depth, unacknowledged deliveries, processing failures, duplicate counts, and acknowledgement latency. Receive Maximum limits protocol in-flight QoS 1/2 deliveries, not the size of the Go queue. - -If the transaction must also trigger a later MQTT publication, do not commit the database and then rely on an immediate publish alone: a crash between the commit and publication can lose the response. Store an outbox entry in the same transaction and publish it from a retrying worker. Section 14 develops that crash-recovery pattern. - -📚 **Builds on:** [Lesson 5](lessons/05-contracts-validation.md) validates topics and payloads; [Lesson 6](lessons/06-concurrency-cancellation.md) explains callbacks, manual acknowledgement, and bounded queues; [Lesson 7](lessons/07-command-response.md) introduces stable IDs and idempotent effects; [Lesson 16](lessons/16-mqtt5-controls.md) covers Receive Maximum. Section 14 adds the transactional outbox. +📚 **Detailed teaching and experiments:** [Lesson 13](teaching/course-source/13-persist-reports-command-state.md). 1. [ ] Add a dedicated lab PostgreSQL instance and migrations for devices, commands, command results, and sales reports. Use `database/sql` with pgx stdlib to match the observed connector style. 2. [ ] Move important in-memory repositories behind small interfaces. Use contexts, query deadlines, bounded connection pools, and transaction boundaries. @@ -290,6 +288,9 @@ If the transaction must also trigger a later MQTT publication, do not commit the ## Section 14 — Close database/MQTT failure gaps +Detailed lesson: [Lesson 14 — Close database/MQTT failure gaps](teaching/course-source/14-database-mqtt-failure-gaps.md). +Interactive guide: [Lesson 14 — transactional outbox](teaching/lessons/0014-outbox-failure-gaps.html). + **Outcome:** Accepted work can be recovered across failures between storage and publication. 1. [ ] Draw the failure windows between saving a command, publishing it, receiving PUBACK, and recording the device result. @@ -302,6 +303,8 @@ If the transaction must also trigger a later MQTT publication, do not commit the ## Section 15 — Split API and ingestion processes +Interactive guide: [Lesson 15 — Split API and ingestion](teaching/lessons/0015-process-boundaries.html). + **Outcome:** Your application resembles the backend's separate process responsibilities. 1. [ ] Extract `cmd/api` and `cmd/ingest` with separate lifecycle contexts and client IDs. Decide explicitly which process or worker owns publication. @@ -314,7 +317,8 @@ If the transaction must also trigger a later MQTT publication, do not commit the ## Section 16 — Explore advanced MQTT 5 controls and scaling -Detailed lesson: [Section 16 explanations and experiments](lessons/16-mqtt5-controls.md). +Detailed lesson: [Section 16 explanations and experiments](teaching/course-source/16-mqtt5-controls.md). +Interactive guide: [Lesson 16 — MQTT 5 controls](teaching/lessons/0016-mqtt5-controls.html). **Outcome:** You can choose MQTT features based on measured behavior. @@ -328,6 +332,8 @@ Detailed lesson: [Section 16 explanations and experiments](lessons/16-mqtt5-cont ## Section 17 — Operate and diagnose the service +Interactive guide: [Lesson 17 — Operate and diagnose](teaching/lessons/0017-operations-diagnostics.html). + **Outcome:** You can identify whether a failure is in HTTP, the application, broker, device, or database. 1. [ ] Add structured logs with stable command/report IDs and safe error categories. Add metrics for reconnects, rejected messages, queue depth, duplicate handling, publish latency, response latency, timeouts, and DB failures. @@ -338,8 +344,31 @@ Detailed lesson: [Section 16 explanations and experiments](lessons/16-mqtt5-cont **Done when:** Another person could reproduce and diagnose a failed command using your runbook without reading all your code. Relevant Go tests and race checks pass for the learning application. +## Section 17A — Practice the advanced DevOps service lifecycle + +Detailed lesson: [Advanced DevOps service lifecycle](teaching/course-source/17a-advanced-devops-lifecycle.md). + +This separate lesson builds on the junior-level DevOps knowledge assumed by the course. It follows the backend from a source change through validation, artifact creation, deployment, rollout, operation, recovery, and improvement. Use the isolated learning project and synthetic devices; Kubernetes is not required, and production deployment is outside this course. + +**Outcome:** You can design and explain a repeatable release lifecycle for the backend, simulator, broker configuration, and database changes, with evidence and a recovery path at each stage. + +1. [ ] Map source, CI, build artifact, configuration/secrets, runtime dependencies, and operator ownership. Identify which state belongs to PostgreSQL, the broker, and each service process. +2. [ ] Define pipeline gates for formatting, static checks, unit tests, race checks where relevant, and isolated broker/database integration checks. Separate checks that can run on every change from environment-dependent acceptance checks. +3. [ ] Package the Go services as reproducible, versioned images. Run as a non-root user, keep credentials out of the image and build logs, handle termination signals, and define resource and health-check behavior. +4. [ ] Design environment configuration and secret injection, validation, rotation, and least-privilege runtime identities. Make development, test, and staging differences explicit without copying production secrets into the lab. +5. [ ] Plan compatible releases for services and database schema changes. Practice an expand/migrate/contract sequence, immutable artifact promotion, rollout gates, and rollback limits when data has already changed. +6. [ ] Exercise deployment and shutdown with readiness, draining, in-flight command behavior, and independent API/ingestion restarts. Decide which changes can roll back automatically and which require operator judgment. +7. [ ] Define service indicators and objectives, alert conditions, dashboards, and an incident sequence from detection through mitigation, recovery, and review. Use the telemetry introduced in Sections 6 and 17. +8. [ ] Write and rehearse backup/restore and disaster-recovery procedures for PostgreSQL and broker state. Set example recovery-point and recovery-time objectives, then collect evidence from a restore drill rather than treating a successful backup as proof. +9. [ ] Review dependency and base-image updates, vulnerability findings, artifact provenance, access to deployment credentials, and audit records. Decide how a release is paused when a gate fails. +10. [ ] Produce a release package: pipeline diagram, deployment checklist, rollback/recovery plan, runbook links, and a post-deployment verification record for a simulated release. + +**Done when:** A teammate can follow your documented path from a code change to a verified lab release, identify the owner of each state boundary, and recover from one injected deployment or dependency failure without losing durable commands or reports. + ## Section 18 — Prepare for the MQTT 5 backend handover +Interactive guide: [Lesson 18 — Backend handover](teaching/lessons/0018-backend-handover.html). + **Outcome:** You can inspect, explain, and safely maintain the delivered system. 1. [ ] Reinspect the delivered commit, dependencies, broker configuration, device protocol contracts, and deployment topology. Build an explicit MQTT 3.1.1-to-5 behavior comparison rather than an import-replacement checklist. @@ -359,13 +388,14 @@ Detailed lesson: [Section 16 explanations and experiments](lessons/16-mqtt5-cont | 8 | HTTP command reaches the device and exposes an accurate status | | 12 | Secured lab demonstrates sales, catalog, configuration, and health flows | | 15 | Persistent API/ingestion processes recover independently | +| 17A | A simulated release passes its gates, is verified after rollout, and has a rehearsed recovery path | | 18 | You can trace, test, diagnose, and review the delivered backend | ## How to start a lesson with the tutor > Read `prompt.md` and `COURSE.md`. Start Section 1, exercise 1. I know MQTT 3.1.1 concepts. Explain only what I need for this exercise, give me a small task and expected result, then wait for my attempt. Review my result before moving on. Do not generate the complete application. -For later sessions, replace the section and exercise number and include your current code, command output, or question. The tutor should ask about Go experience only where it affects the current exercise; Docker availability is established in Section 1. +For later sessions, replace the section and exercise number and include your current code, command output, or question. Assume the learner is a mid-level Go backend engineer with four years of experience; do not recheck their Go level or explain syntax unless asked. Docker availability is established in Section 1. ## Official references diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f6f9092 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +GO ?= go +DOCKER_COMPOSE ?= docker compose + +CONFIG ?= configs/lab.toml +DEVICE_ID ?= device-001 +BACKEND_CLIENT_ID ?= lab-backend +SIMULATOR_CLIENT_ID ?= lab-simulator-$(DEVICE_ID) +BIN_DIR ?= bin + +.PHONY: help build configcheck broker-up broker-down broker-logs backend simulator lab-up + +help: ## List available commands + @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf "%-16s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +build: ## Build the backend, simulator, and config checker into $(BIN_DIR)/ + @mkdir -p $(BIN_DIR) + $(GO) build -o $(BIN_DIR)/backend ./cmd/backend + $(GO) build -o $(BIN_DIR)/simulator ./cmd/simulator + $(GO) build -o $(BIN_DIR)/configcheck ./cmd/configcheck + +configcheck: ## Validate the lab configuration and credentials + $(GO) run ./cmd/configcheck -config $(CONFIG) -username backend + +broker-up: ## Start the Mosquitto lab broker in the background + $(DOCKER_COMPOSE) -f compose.yml up -d broker + +broker-down: ## Stop the broker without deleting its volumes + $(DOCKER_COMPOSE) -f compose.yml down + +broker-logs: ## Follow broker logs + $(DOCKER_COMPOSE) -f compose.yml logs --follow broker + +backend: broker-up ## Ensure the broker is running, then run the backend (Ctrl+C to stop) + $(GO) run ./cmd/backend -config $(CONFIG) -username backend -client-id $(BACKEND_CLIENT_ID) -device-id $(DEVICE_ID) + +simulator: broker-up ## Ensure the broker is running, then run the simulator (Ctrl+C to stop) + $(GO) run ./cmd/simulator -config $(CONFIG) -username simulator -client-id $(SIMULATOR_CLIENT_ID) -device-id $(DEVICE_ID) + +lab-up: build ## Build everything, start the broker, then run both services together + $(DOCKER_COMPOSE) -f compose.yml up -d broker + @set -m; \ + trap 'kill "$$simulator_pid" "$$backend_pid" 2>/dev/null || true; wait "$$simulator_pid" "$$backend_pid" 2>/dev/null || true' EXIT INT TERM; \ + $(BIN_DIR)/simulator -config $(CONFIG) -username simulator -client-id $(SIMULATOR_CLIENT_ID) -device-id $(DEVICE_ID) & simulator_pid=$$!; \ + $(BIN_DIR)/backend -config $(CONFIG) -username backend -client-id $(BACKEND_CLIENT_ID) -device-id $(DEVICE_ID) & backend_pid=$$!; \ + wait "$$simulator_pid" "$$backend_pid" diff --git a/cmd/backend/main.go b/cmd/backend/main.go index d013f62..cd22dff 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -5,12 +5,16 @@ import ( "encoding/json" "fmt" "log" + "log/slog" "os" "os/signal" "syscall" "time" "example.com/mqtt5-learning/internal/config" + "example.com/mqtt5-learning/internal/contracts" + "example.com/mqtt5-learning/internal/deviceview" + "example.com/mqtt5-learning/internal/dispatch" "example.com/mqtt5-learning/internal/mqtt" ) @@ -26,47 +30,86 @@ func run() error { return fmt.Errorf("failed to load config: %w", err) } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + lifecycleCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + transportCtx, stopTransport := context.WithCancel(context.Background()) + defer stopTransport() - base := "lab/v1/devices/" + cfg.MQTT.DeviceID + store := deviceview.NewStore() + service, err := deviceview.NewService(store, slog.Default(), 90*time.Second) + if err != nil { + return fmt.Errorf("create device-view service: %w", err) + } + + telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry) + if err != nil { + return err + } + responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses) + if err != nil { + return err + } + commandTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindCommands) + if err != nil { + return err + } inbox := make(chan mqtt.Message, 16) - - manager, err := mqtt.Start(ctx, cfg, []string{base + "/telemetry", base + "/responses"}, inbox) + manager, err := mqtt.Start(transportCtx, cfg, []contracts.TopicFilter{telemetryFilter, responsesFilter}, inbox) if err != nil { return fmt.Errorf("failed to start MQTT client: %w", err) } defer mqtt.Stop(manager) + telemetryPool := dispatch.New(context.Background(), 2, 16) + importantPool := dispatch.New(context.Background(), 2, 32) + receiver := &dispatch.Receiver{ + Telemetry: telemetryPool, + Important: importantPool, + Process: service.HandleMessage, + } + 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()) + }() + ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() - runID := time.Now().UnixNano() sequence := 0 for { select { - case <-ctx.Done(): - log.Print("shutting down") + case <-lifecycleCtx.Done(): + log.Print("shutting down; draining accepted work before MQTT disconnect") return nil - case msg := <-inbox: - log.Printf("received message on topic %s retained=%t payload=%s", msg.Topic, msg.Retain, string(msg.Payload)) - + case message := <-inbox: + outcome, submitErr := receiver.Submit(lifecycleCtx, message) + if submitErr != nil || outcome != dispatch.Accepted { + slog.Warn("message admission rejected", "topic", message.Topic, "outcome", outcome, "error", submitErr) + } case <-ticker.C: sequence++ - payload, err := json.Marshal(struct { - ID string `json:"id"` - Command string `json:"command"` - }{ - ID: fmt.Sprintf("ping-%d-%d", runID, sequence), Command: "ping", + id := fmt.Sprintf("ping-%d-%d", runID, sequence) + payload, marshalErr := json.Marshal(contracts.Command{ + SchemaVersion: contracts.SchemaVersionOne, + MessageID: id, + DeviceID: cfg.MQTT.DeviceID, + OccurredAt: time.Now().UTC().Format(time.RFC3339), + CommandID: id, + Command: "ping", }) - if err != nil { - return fmt.Errorf("failed to marshal payload: %w", err) + if marshalErr != nil { + return fmt.Errorf("failed to marshal payload: %w", marshalErr) } - if err := mqtt.Publish(ctx, manager, base+"/commands", payload); err != nil { - log.Printf("command publication failed or outcome uncertain: %v", err) + if publishErr := mqtt.Publish(lifecycleCtx, manager, commandTopic, payload); publishErr != nil { + log.Printf("command publication failed or outcome uncertain: %v", publishErr) } } } - } diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 5e4d592..2cafa4f 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -3,14 +3,19 @@ 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() { @@ -29,59 +34,111 @@ func run() error { } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - // Build this device's topic namespace and bounded receive queue. - base := "lab/v1/devices/" + cfg.MQTT.DeviceID + 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(ctx, cfg, []string{base + "/commands"}, inbox) + 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: - payload, err := json.Marshal(struct { - DeviceID string `json:"device_id"` - Time string `json:"time"` - }{cfg.MQTT.DeviceID, time.Now().UTC().Format(time.RFC3339)}) + 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, base+"/telemetry", payload); err != nil { + if err = mqtt.Publish(ctx, manager, telemetryTopic, payload); err != nil { log.Printf("heartbeat: %v", err) } case message := <-inbox: - // Decode into a local command value only after rejecting retained messages. - var command struct { - ID string `json:"id"` - Command string `json:"command"` - } - if message.Retain { - log.Print("ignoring retained command") - continue - } - if err := json.Unmarshal(message.Payload, &command); err != nil || command.ID == "" || command.Command != "ping" { - log.Print("ignoring malformed or unsupported command") - continue - } - // Echo the command ID so the backend can associate this pong with its ping. - response, err := json.Marshal(struct { - ID string `json:"id"` - DeviceID string `json:"device_id"` - Status string `json:"status"` - }{command.ID, cfg.MQTT.DeviceID, "pong"}) - if err != nil { - return err - } - if err := mqtt.Publish(ctx, manager, base+"/responses", response); err != nil { - log.Printf("response: %v", err) + 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) +} diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..e3a032d --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,17 @@ +# Domain Docs + +This is a single-context repository. Engineering skills should use the root glossary and ADR directory when exploring domain concepts or decisions. + +## Before exploring + +- Read `GLOSSARY.md` at the repository root when it exists. +- Read relevant decisions under `docs/adr/` when that directory exists. +- If either is absent, proceed without flagging it or creating it just for setup. Domain-modeling work can create these documents when it resolves terminology or a decision. + +## Use the glossary's vocabulary + +When an output names a domain concept, use the term defined in `GLOSSARY.md`. If a needed concept is not defined, note the gap for domain-modeling work rather than inventing a competing term. + +## Flag ADR conflicts + +If a proposed change conflicts with an existing ADR, identify the ADR and explain the conflict instead of silently overriding it. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..6d9333a --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,21 @@ +# Issue tracker: Local Markdown with Gitea as a companion + +The local issue tracker is the source of truth for this repository. Store issues and specifications as Markdown files under `.scratch/`. + +## Local conventions + +- Use one directory per feature or effort: `.scratch//`. +- Store its specification at `.scratch//spec.md`. +- Store implementation issues in `.scratch//issues/-.md`, numbered from `01`, one issue per file. +- Record triage state in a `Status:` line near the top of each issue file; use the mappings in `docs/agents/triage-labels.md`. +- Append comments and conversation history under a `## Comments` heading. + +## Gitea Issues + +Gitea Issues may also be used as a companion tracker. The repository remote supplied by the user is `ssh://git@git.my-rm.com:2222/Robot-Market/mqtt-5-learning.git`. There is no automatic synchronization: when an issue is represented in both places, keep the local Markdown file authoritative and include the corresponding Gitea issue link there. Do not assume a Gitea CLI or API workflow is configured. + +## Skill operations + +- When a skill says to publish an issue or specification, create the local Markdown file under `.scratch//`. +- When a skill says to fetch a ticket, read the referenced local file; the user may provide its path or issue number. +- For wayfinding, store the map at `.scratch//map.md` and child tickets under `.scratch//issues/`. Child tickets use `Type:` (`research`, `prototype`, `grilling`, or `task`), `Status:` (`claimed` or `resolved`), and an optional `Blocked by:` list. Claim by setting `Status: claimed`; resolve by adding an `## Answer`, setting `Status: resolved`, and adding a context pointer to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..6baadea --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,13 @@ +# Triage Labels + +The skills use five canonical triage roles. Use these same strings in local issue `Status:` lines and, when mirroring an issue in Gitea, as its labels. + +| Label in mattpocock/skills | Label in this repo | Meaning | +| --- | --- | --- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on the reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified and ready for an agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a triage role, use the corresponding label string from this table. diff --git a/go.mod b/go.mod index 9617084..3de633f 100644 --- a/go.mod +++ b/go.mod @@ -2,17 +2,19 @@ module example.com/mqtt5-learning go 1.27.1 -require github.com/eclipse/paho.golang v0.23.0 +require ( + github.com/eclipse/paho.golang v0.23.0 + github.com/knadh/koanf/parsers/toml/v2 v2.2.0 + github.com/knadh/koanf/providers/confmap v1.0.0 + github.com/knadh/koanf/providers/file v1.2.0 + github.com/knadh/koanf/v2 v2.3.7 +) require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect - github.com/knadh/koanf/parsers/toml/v2 v2.2.0 // indirect - github.com/knadh/koanf/providers/confmap v1.0.0 // indirect - github.com/knadh/koanf/providers/file v1.2.0 // indirect - github.com/knadh/koanf/v2 v2.3.7 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index aa29fed..25ad5f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2b82207 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/contracts/decode_test.go b/internal/contracts/decode_test.go index 7bb4247..1678bb1 100644 --- a/internal/contracts/decode_test.go +++ b/internal/contracts/decode_test.go @@ -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") } diff --git a/internal/contracts/message.go b/internal/contracts/message.go index be6bf83..cb643a8 100644 --- a/internal/contracts/message.go +++ b/internal/contracts/message.go @@ -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) - } -} diff --git a/internal/contracts/topic.go b/internal/contracts/topic.go new file mode 100644 index 0000000..6cc1cbb --- /dev/null +++ b/internal/contracts/topic.go @@ -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 +} diff --git a/internal/contracts/topics.go b/internal/contracts/topics.go deleted file mode 100644 index 6cdb93a..0000000 --- a/internal/contracts/topics.go +++ /dev/null @@ -1 +0,0 @@ -package contracts diff --git a/internal/deviceview/service.go b/internal/deviceview/service.go new file mode 100644 index 0000000..3060455 --- /dev/null +++ b/internal/deviceview/service.go @@ -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) +} diff --git a/internal/deviceview/store.go b/internal/deviceview/store.go new file mode 100644 index 0000000..0d39813 --- /dev/null +++ b/internal/deviceview/store.go @@ -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 +} diff --git a/internal/dispatch/pool.go b/internal/dispatch/pool.go new file mode 100644 index 0000000..c4794dc --- /dev/null +++ b/internal/dispatch/pool.go @@ -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) + } + } +} diff --git a/internal/dispatch/pool_test.go b/internal/dispatch/pool_test.go new file mode 100644 index 0000000..b3ea77f --- /dev/null +++ b/internal/dispatch/pool_test.go @@ -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) + } +} diff --git a/internal/dispatch/receiver.go b/internal/dispatch/receiver.go new file mode 100644 index 0000000..eaad2b8 --- /dev/null +++ b/internal/dispatch/receiver.go @@ -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) }) +} diff --git a/internal/dispatch/shutdown.go b/internal/dispatch/shutdown.go new file mode 100644 index 0000000..06e64a9 --- /dev/null +++ b/internal/dispatch/shutdown.go @@ -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 +} diff --git a/internal/mqtt/client.go b/internal/mqtt/client.go index 8a1340f..b2bd6c1 100644 --- a/internal/mqtt/client.go +++ b/internal/mqtt/client.go @@ -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) } -} \ No newline at end of file +} diff --git a/prompt.md b/prompt.md index c660e05..d18c558 100644 --- a/prompt.md +++ b/prompt.md @@ -9,7 +9,7 @@ Current learner context and course I already understand MQTT 3.1.1 concepts. Focus on practical Go backend development and MQTT 5 differences rather than repeating the introductory MQTT syllabus. My goal is to maintain Robot Market after its MQTT 5 version is delivered to me. Use the existing backend as a read-only reference, and reinspect the delivered version at handover. Follow COURSE.md in this project, section by section and exercise by exercise. It is the requested ordered learning course; do not restart planning or require another sprint breakdown before beginning a lesson I request. -Detailed instructions for Sections 1–11 and 16 live in lessons/. Sections 1–4 include reference programs; later lessons provide explained fragments and guided exercises. Use lessons/README.md for concept coverage and the remaining course sections. Teach from them incrementally. The learner explicitly requested code in these lesson files; do not hide those examples, while still allowing them to attempt the exercises themselves. +Detailed Markdown implementation lessons for Sections 1–11 and 16 live in teaching/course-source/. Sections 1–4 include reference programs; later lessons provide explained fragments and guided exercises. Use teaching/course-source/README.md for concept coverage and the remaining course sections. Teach from them incrementally. The learner explicitly requested code in these Markdown lesson files; do not hide those examples, while still allowing them to attempt the exercises themselves. The interactive HTML lessons the learner is following live under teaching/lessons/. The lesson baseline uses github.com/eclipse/paho.golang v0.23.0, the selected course baseline, requiring Go 1.24 or newer. Matching source is in references/paho.golang-v0.23.0/. Verify releases again before any future upgrade. Existing project context diff --git a/teaching/GLOSSARY.md b/teaching/GLOSSARY.md new file mode 100644 index 0000000..f8ae2aa --- /dev/null +++ b/teaching/GLOSSARY.md @@ -0,0 +1,5 @@ +# Go MQTT Vending Backend Glossary + +This glossary records terms the learner has demonstrated they can use accurately. Newly introduced terms remain in lessons and references until that evidence exists. + +## Terms diff --git a/teaching/MISSION.md b/teaching/MISSION.md new file mode 100644 index 0000000..ab1106b --- /dev/null +++ b/teaching/MISSION.md @@ -0,0 +1,18 @@ +# Mission: Go backend for MQTT vending devices + +## Why +Build and maintain a Go backend that communicates with Mosquitto over MQTT 5, receives vending-device telemetry and logs, exposes status, and sends safe commands and updates. + +## Success looks like +- Trace a device operation from HTTP request through MQTT delivery to device result without confusing their acknowledgements. +- Implement and maintain bounded, validated, observable Go handlers for device messages and commands. +- Diagnose failures across the API, backend, broker, and device boundaries. + +## Constraints +- Use this repository's simulator and disposable lab; keep the production backend read-only. +- Assume Go and Docker basics and prior MQTT 3.1.1 study; focus on MQTT 5 behavior and operational consequences. +- Lessons should be brief, use an overview before details, include meaningful emojis, and require the learner to retrieve or apply an idea. + +## Out of scope +- Sending real commands to vending machines or changing production systems. +- Treating broker delivery as proof that a device performed a business action. diff --git a/teaching/NOTES.md b/teaching/NOTES.md new file mode 100644 index 0000000..0ebcb74 --- /dev/null +++ b/teaching/NOTES.md @@ -0,0 +1,12 @@ +# Teaching Notes + +- Learner's stated mission: implement and maintain a Go backend with MQTT 5 and Mosquitto for vending-device telemetry, logs, status, commands, and updates. +- Learner initially reported studying through Section 5, then reported finishing through Lesson 5A and requested learner-facing lessons through the end of the Section 18 course plan. HTML guides now cover Lessons 9–18; Lesson 11B is optional. Lesson 6–8 HTML guides already existed. +- Learner is a mid-level Go backend engineer with four years of experience and is confident in Go syntax and core language concepts. Do not teach Go basics or recheck Go proficiency; focus Go explanations on design choices and language behavior that materially affects ownership, concurrency, cancellation, error handling, or API shape. +- Learner has junior-level DevOps experience. Advanced delivery and operations lifecycle practices belong in the separate Section 17A lesson, introduced after the operations/diagnostics section and before backend handover; keep the main MQTT implementation path focused on the requested engineering outcomes. +- For code teaching, begin with what the file does, then give each function a short summary followed by detail. Use emojis as visual landmarks. +- Every standalone lesson must contain the complete code needed for that lesson: full contents for files it creates or changes, including imports, types, and integration/wiring. Do not substitute fragments or links to another lesson for omitted code. Clearly distinguish existing prerequisites from new lesson code, and keep code self-contained and consistent with the course checkout. +- Preserve substantive implementation exercises: ask the learner to make and defend design choices, implement the relevant slice, and explain what evidence proves and does not prove. Avoid beginner Go instruction and generic conceptual summaries. +- Keep the Course 7–9 command and session lessons consistent with COURSE.md: in-memory command state is not durable; the Section 8 API is a lab-only unauthenticated endpoint until Section 11; durable command acceptance/recovery begins in Sections 13–14; distinguish retained latest state from queued one-shot delivery and protocol QoS from business effects. +- Keep lessons short and interactive. Do not mark a concept mastered merely because it appeared in a lesson; wait for the learner's answer or implementation. The request to create lessons is not evidence that Lessons 6–18 have been completed. +- Keep examples in the disposable lab and do not modify production systems. diff --git a/teaching/README.md b/teaching/README.md new file mode 100644 index 0000000..9d8aa38 --- /dev/null +++ b/teaching/README.md @@ -0,0 +1,25 @@ +# Go MQTT Vending Backend — Teaching Workspace + +This directory contains short, interactive HTML teaching guides through Lesson 18. The full Markdown implementation course and its code/broker experiments live in `course-source/`, alongside the HTML lessons. This workspace adds focused retrieval practice and printable references. + +## Start here + +1. Since the learner has reported finishing Lesson 5A, continue with [Lesson 6 — bounded message work](lessons/0006-bounded-message-work.html). +2. Follow with [Lesson 7 — command state](lessons/0007-command-state.html), [Lesson 8 — HTTP acceptance vs device completion](lessons/0008-http-acceptance-vs-completion.html), [Lesson 9 — session recovery](lessons/0009-session-recovery.html), [Lesson 10 — expiry and presence](lessons/0010-expiry-presence-state.html), [Lesson 11 — security boundaries](lessons/0011-security-boundaries.html), [Lesson 12 — vending workflows](lessons/0012-vending-workflows.html), and [Lesson 13 — durable persistence](lessons/0013-persist-reports-command-state.html). +3. Continue through [Lesson 14 — transactional outbox](lessons/0014-outbox-failure-gaps.html), [Lesson 15 — split API and ingestion](lessons/0015-process-boundaries.html), [Lesson 16 — MQTT 5 controls](lessons/0016-mqtt5-controls.html), [Lesson 17 — operations and diagnosis](lessons/0017-operations-diagnostics.html), and [Lesson 18 — backend handover](lessons/0018-backend-handover.html), the end of the course plan. +4. [Lesson 11B — Dynamic Security](lessons/0011b-dynamic-security.html) is an optional broker-provisioning extension. +5. Keep the [message-processing lifecycle reference](reference/message-processing-lifecycle.html) and [HTTP/MQTT evidence quick reference](reference/http-mqtt-evidence.html) nearby. +6. Each HTML lesson is a short retrieval and experiment guide. Use its linked `course-source/` lesson or `COURSE.md` checklist for detailed implementation instructions, code, and broker-specific commands. Attempt the exercise before opening solution material where applicable. + +The next lesson should follow the learner's answer and code review. Do not infer mastery from opening or reading a page. + +## Workspace map + +- [MISSION.md](MISSION.md) — the real-world backend goal and its boundaries. +- [RESOURCES.md](RESOURCES.md) — primary references and the lab code map. +- [GLOSSARY.md](GLOSSARY.md) — terms promoted only after demonstrated understanding. +- [NOTES.md](NOTES.md) — teaching preferences and current sequence state. +- `lessons/` — short HTML lessons and retrieval practice through Lesson 18, with optional Lesson 11B. +- `reference/` — concise reference pages designed for review and printing. +- `assets/` — the shared stylesheet and interactive quiz behavior. +- `learning-records/` — decision-grade progress evidence, not a session log. diff --git a/teaching/RESOURCES.md b/teaching/RESOURCES.md new file mode 100644 index 0000000..5195524 --- /dev/null +++ b/teaching/RESOURCES.md @@ -0,0 +1,38 @@ +# Go MQTT Vending Backend Resources + +## Knowledge + +- [Go `net/http` package](https://pkg.go.dev/net/http) + Primary API reference for `ServeMux`, request path values, handlers, server timeouts, and shutdown; use when implementing or reviewing the HTTP boundary. +- [Go 1.22 routing enhancements](https://go.dev/blog/routing-enhancements) + Go team's explanation of method-aware routes and path wildcards; use to understand patterns such as `POST /devices/{deviceID}/commands`. +- [RFC 9110, HTTP Semantics](https://datatracker.ietf.org/doc/rfc9110/) + Normative definition of HTTP status semantics, including 202 Accepted; use when designing API responses for asynchronous device work. +- [OASIS MQTT Version 5.0 Standard](https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html) + Normative MQTT 5 packet and reason-code behavior; use to distinguish a QoS 1 PUBACK from application-level device completion. +- [Repository Section 8 — HTTP commands](course-source/08-http-commands.md) + The existing end-to-end lab implementation and exercises; use as the code reference after attempting the focused teaching lesson. +- [Repository Sections 4–5 — topics, contracts, and validation](course-source/04-publish-subscribe-simulator.md) + Section 4 defines the topic hierarchy and typed builders; Section 5 provides the complete payload contracts, topic parsing, decoding, and validation examples. +- [Repository Section 5A — backend message processing](course-source/05a-backend-message-processing.md) + Complete heartbeat service/store slice and freshness semantics for the application boundary lesson. +- [Repository Section 6 — concurrency and cancellation](course-source/06-concurrency-cancellation.md) + Bounded queue, dispatch routing, acknowledgement timing, and shutdown implementation. +- [Repository Section 7 — command/response](course-source/07-command-response.md) + Command state machine, correlation, device deduplication, and failure cases. +- [Go `context` package](https://pkg.go.dev/context) + Standard cancellation and deadline propagation used by workers and MQTT operations. +- [Go `sync` package](https://pkg.go.dev/sync) + Mutex and synchronization semantics for the shared application stores and pool lifecycle. +- [Paho Go MQTT v0.23.0](https://pkg.go.dev/github.com/eclipse/paho.golang/paho@v0.23.0) + Version-pinned MQTT 5 client API reference; use the matching package docs and source when interpreting callback and acknowledgement behavior. + +## Wisdom (Communities) + +- [Go Forum](https://forum.golangbridge.org/) + Community discussion for Go design and debugging questions; bring a minimal reproducer and explain what evidence you observed. + +## Gaps + +- The HTML lessons focus on one skill at a time; use the matching Markdown source for full implementation and tests. +- The first pass does not claim mastery. Select the next lesson or review based on the learner's answers and code exercise. diff --git a/teaching/assets/lesson.css b/teaching/assets/lesson.css new file mode 100644 index 0000000..470400f --- /dev/null +++ b/teaching/assets/lesson.css @@ -0,0 +1,65 @@ +:root { + color-scheme: light; + --ink: #202a35; + --muted: #5a6874; + --paper: #fbfaf6; + --panel: #fff; + --line: #d8dedc; + --accent: #146b65; + --accent-soft: #e4f2ef; + --warm: #b65a32; + --good: #17643d; + --bad: #9b342d; + --shadow: 0 18px 55px rgb(28 52 50 / 8%); + font: 16px/1.65 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +body { margin: 0; color: var(--ink); background: var(--paper); } +a { color: var(--accent); text-underline-offset: .18em; } +a:hover { color: var(--warm); } +.page { width: min(920px, calc(100% - 2rem)); margin: 0 auto; padding: 3rem 0 5rem; } +.eyebrow { color: var(--accent); font-size: .78rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } +h1, h2, h3 { font-family: Georgia, "Times New Roman", serif; line-height: 1.18; } +h1 { max-width: 16ch; margin: .45rem 0 1rem; font-size: clamp(2.45rem, 7vw, 4.6rem); letter-spacing: -.045em; } +h2 { margin-top: 3rem; font-size: clamp(1.65rem, 4vw, 2.2rem); } +h3 { margin-bottom: .35rem; font-size: 1.25rem; } +.dek { max-width: 66ch; color: var(--muted); font-size: 1.18rem; } +.meta { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.4rem 0 2rem; } +.pill { padding: .28rem .68rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .82rem; } +.card { margin: 1.2rem 0; padding: 1.35rem 1.5rem; border: 1px solid var(--line); border-radius: 1rem; background: var(--panel); box-shadow: var(--shadow); } +.callout { border-left: 4px solid var(--accent); background: var(--accent-soft); } +.question { color: var(--accent); font-weight: 800; } +.flow { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .55rem; margin: 1.6rem 0; } +.step { min-width: 145px; padding: .85rem; border: 1px solid var(--line); border-radius: .75rem; background: var(--panel); text-align: center; } +.arrow { color: var(--warm); font-size: 1.3rem; font-weight: 800; } +code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +code { padding: .08rem .28rem; border-radius: .25rem; background: #edf0ee; font-size: .92em; } +pre { overflow-x: auto; padding: 1rem 1.15rem; border-radius: .75rem; background: #18252d; color: #f2f5f3; line-height: 1.55; } +pre code { padding: 0; background: transparent; color: inherit; } +.quiz { margin-top: 1rem; } +.question-block { padding: 1rem 0; border-top: 1px solid var(--line); } +.option { display: block; margin: .55rem 0; padding: .7rem .85rem; border: 1px solid var(--line); border-radius: .6rem; cursor: pointer; } +.option:hover { border-color: var(--accent); background: var(--accent-soft); } +.option input { margin-right: .55rem; accent-color: var(--accent); } +button { padding: .7rem 1rem; border: 0; border-radius: .6rem; color: #fff; background: var(--accent); font: inherit; font-weight: 750; cursor: pointer; } +button:hover { background: #0d514d; } +.feedback { min-height: 1.8em; margin: .75rem 0 0; font-weight: 700; } +.feedback[data-state="correct"] { color: var(--good); } +.feedback[data-state="incorrect"] { color: var(--bad); } +.small { color: var(--muted); font-size: .92rem; } +.two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: .72rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } +th { color: var(--accent); } +fieldset { min-width: 0; margin: 0; padding: 0; border: 0; } +legend { margin-bottom: .65rem; } +footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .9rem; } +@media (max-width: 650px) { .two-col { grid-template-columns: 1fr; } .page { padding-top: 2rem; } } +@media print { + :root { --paper: #fff; --panel: #fff; --shadow: none; } + .page { width: 100%; padding: 0; } + .card { break-inside: avoid; box-shadow: none; } + button, .quiz { display: none; } + a { color: inherit; } +} diff --git a/teaching/assets/quiz.js b/teaching/assets/quiz.js new file mode 100644 index 0000000..c4d85b2 --- /dev/null +++ b/teaching/assets/quiz.js @@ -0,0 +1,17 @@ +document.querySelectorAll("form[data-quiz]").forEach((form) => { + form.addEventListener("submit", (event) => { + event.preventDefault(); + const selected = form.querySelector('input[type="radio"]:checked'); + const feedback = form.querySelector("[data-feedback]"); + if (!selected) { + feedback.textContent = "Choose an answer first, then check your reasoning."; + feedback.dataset.state = "incorrect"; + return; + } + const correct = selected.value === form.dataset.correct; + feedback.textContent = correct + ? form.dataset.explanation + : form.dataset.retry || "Not quite. Trace which component produced this evidence, then try again."; + feedback.dataset.state = correct ? "correct" : "incorrect"; + }); +}); diff --git a/lessons/00-system-lifecycle.md b/teaching/course-source/00-system-lifecycle.md similarity index 92% rename from lessons/00-system-lifecycle.md rename to teaching/course-source/00-system-lifecycle.md index b6ca934..b1db13d 100644 --- a/lessons/00-system-lifecycle.md +++ b/teaching/course-source/00-system-lifecycle.md @@ -161,7 +161,7 @@ sequenceDiagram There are separate MQTT delivery hops. A `PUBACK` on the backend's publication confirms the QoS 1 exchange with the broker; it is not a device response. The response is a new application publication. Similarly, receipt by a Go callback does not prove a database write or physical device action. -📚 **Covered in:** [Lesson 4](04-publish-subscribe-simulator.md) implements message exchange. [Lesson 5 — Contracts](05-contracts-validation.md) validates topics and payloads; [Lesson 6 — Concurrency](06-concurrency-cancellation.md) covers callbacks, queues, and acknowledgement timing; [Lesson 7 — Command/response](07-command-response.md) tracks device results; [Lesson 8 — HTTP commands](08-http-commands.md) exposes the flow to an API caller. +📚 **Covered in:** [Lesson 4](04-publish-subscribe-simulator.md) implements message exchange. [Lesson 5 — Contracts](05-contracts-validation.md) validates topics and payloads; [Lesson 5A](05a-backend-message-processing.md) turns validated heartbeats into an in-memory device view; [Lesson 6 — Concurrency](06-concurrency-cancellation.md) covers callbacks, queues, and acknowledgement timing; [Lesson 7 — Command/response](07-command-response.md) tracks device results; [Lesson 8 — HTTP commands](08-http-commands.md) exposes the flow to an API caller. --- @@ -197,23 +197,24 @@ stateDiagram-v2 | Lifecycle area | Lessons | Evidence to understand | |---|---|---| -| Broker setup, credentials, and topic permissions | [1](01-lab-setup.md), [11](11-security-boundaries.md), [11B](11b-dynamic-security.md, optional) | Mosquitto owns broker authentication and topic ACL enforcement; optional live per-device provisioning | +| Broker setup, credentials, and topic permissions | [1](01-lab-setup.md), [11](11-security-boundaries.md), [11B](11b-dynamic-security.md) optional | Mosquitto owns broker authentication and topic ACL enforcement; optional live per-device provisioning | | Go client configuration | [2](02-go-connection.md), [3](03-typed-configuration.md) | Effective settings, identity, validation, and precedence | | CONNECT, CONNACK, reconnect, and shutdown | [2](02-go-connection.md), [4](04-publish-subscribe-simulator.md), [9](09-sessions-recovery.md) | What connection acceptance proves and what must be restored | | Topic names, filters, wildcards, and routing | [1](01-lab-setup.md), [4](04-publish-subscribe-simulator.md), [16](16-mqtt5-controls.md) | Concrete topic names, `+`/`#` filter matching, broker routing, and subscription options | | Subscriptions and broker routing | [4](04-publish-subscribe-simulator.md), [9](09-sessions-recovery.md), [16](16-mqtt5-controls.md) | SUBACK results, matching filters, session restoration, shared subscriptions, and options | | QoS 0/1/2, duplicates, and acknowledgement scope | [4](04-publish-subscribe-simulator.md), [6](06-concurrency-cancellation.md), [7](07-command-response.md), [16](16-mqtt5-controls.md) | Per-hop packet evidence, DUP/retries, and why QoS does not make business effects exactly once | | Topic and payload validation in Go | [5](05-contracts-validation.md) | `ParseTopic` checks topic shape and kind; `Decode` validates JSON, schema, route/body device identity, and message values before handling | +| Application state after validation | [5A](05a-backend-message-processing.md) | A valid heartbeat updates a simple in-memory device view; freshness is distinct from connection and machine health | | Callback work, queues, and overload | [6](06-concurrency-cancellation.md) | Admission, acknowledgement timing, bounded capacity, and shutdown | | Reliable command/response and HTTP | [7](07-command-response.md), [8](08-http-commands.md) | Correlation, idempotency, timeout, and status reporting | | Sessions, retained state, expiry, and presence | [9](09-sessions-recovery.md), [10](10-expiry-presence-state.md) | Offline delivery prerequisites, retained desired state, message expiry, Will, and heartbeat freshness | | MQTT 5 flow controls and capabilities | [16](16-mqtt5-controls.md) | Receive Maximum, packet limits, reason codes, aliases, and capability boundaries | -The table points to the lessons that explain and exercise each part of the lifecycle. Lessons 1–11 and 16 contain detailed instructions; Sections 12–15 and 17–18 in [the course plan](../COURSE.md) are currently guided checklists that extend the story into vending workflows, persistence, process boundaries, and operations. +The table points to the lessons that explain and exercise each part of the lifecycle. Lessons 1–11 and 16 contain detailed instructions; Sections 12–15 and 17–18 in [the course plan](../../COURSE.md) are currently guided checklists that extend the story into vending workflows, persistence, process boundaries, and operations. ### What validates a topic? -Lesson 5's `contracts.ParseTopic` and each message's `Validate` method are necessary backend checks. They reject malformed routes, unsupported message kinds, and a payload whose claimed `device_id` does not match the topic. The typed decoder then checks the payload contract before application handling. +Lesson 4 introduces `contracts.DeviceTopic`, fleet-wide `contracts.DeviceFilter`, and exact-device `contracts.DeviceSubscription` in `internal/contracts/topic.go`; Lesson 5 adds `contracts.ParseTopic` there and message `Validate` methods in `message.go`. The route rejects malformed paths and selects the expected payload contract, which then checks that its claimed `device_id` matches the topic before application handling. Those checks do not authorize the publisher. A client with valid credentials could still try to publish to another device's topic, so Mosquitto must enforce a least-privilege ACL for both publish and subscribe operations. Lesson 11 tests that broker boundary and the separate HTTP-user authorization boundary; optional Lesson 11B covers per-device ACL provisioning. Authentication identifies a broker client, ACLs authorize topic operations, and Lesson 5 validates message content. Use all three boundaries. @@ -250,4 +251,4 @@ Those checks do not authorize the publisher. A client with valid credentials cou - [Lesson 2 — Go connection](02-go-connection.md) - [Lesson 3 — Typed configuration](03-typed-configuration.md) - [Lesson 4 — Publish, subscribe, simulator](04-publish-subscribe-simulator.md) -- [Course index](../COURSE.md) +- [Course index](../../COURSE.md) diff --git a/lessons/01-lab-setup.md b/teaching/course-source/01-lab-setup.md similarity index 96% rename from lessons/01-lab-setup.md rename to teaching/course-source/01-lab-setup.md index baca060..1dfb23a 100644 --- a/lessons/01-lab-setup.md +++ b/teaching/course-source/01-lab-setup.md @@ -22,6 +22,8 @@ flowchart LR The Compose file starts and isolates the broker; the Mosquitto configuration tells the broker how to listen and enforce authentication/ACLs. A Docker volume preserves broker-owned state. None of these files configures the Go clients—that happens in later lessons. +For the vending-backend goal, this first lesson establishes only the broker boundary: which client may connect, which topics it may use, and how to observe broker activity. It does not yet store a device's status, ingest its diagnostic logs, or execute a vending command. Those are application responsibilities built after the Go clients can exchange messages. + Before each setup step, ask which process owns the setting and what observation will prove it took effect. @@ -33,7 +35,7 @@ Run every shell command in this lesson from the learning project root: cd /home/ryan/Media/Code/Code/robot-market/mqtt-5-learning ``` -Paths such as `cmd/backend/main.go` are relative to this directory. Markdown files under `lessons/` explain the work; create the application files at the paths shown, not inside `lessons/` or `references/`. Code blocks labeled Go, TOML, YAML, or conf are file contents; shell blocks are terminal commands. Create parent directories before saving files. +Paths such as `cmd/backend/main.go` are relative to the repository root. The Markdown implementation lessons are under `teaching/course-source/`; create application files at the paths shown, not inside `teaching/course-source/` or `references/`. Code blocks labeled Go, TOML, YAML, or conf are file contents; shell blocks are terminal commands. Create parent directories before saving files. ## 🛠️ 1. Check the tools and create the module @@ -159,6 +161,8 @@ topic write lab/v1/devices/device-001/responses `user` starts rules for that authenticated username. `topic write` permits publishing; `topic read` permits receiving matching topics. `+` matches exactly one topic level, so the backend rules cover one device-ID segment. The simulator's rules use an exact device ID. Access outside the allowed patterns is denied by this simple ACL configuration. The text file has no extension because Mosquitto reads the path you configure, regardless of its filename suffix. +These ACL patterns intentionally mirror the `lab/v1/devices/{deviceID}/{kind}` hierarchy that Section 4 will define in `internal/contracts/topic.go`. The broker reads its own ACL text; it does not import the Go package. Keep both sides aligned and test denied cross-device access, because a valid Go `TopicName` is not a broker permission. + The simulator can act only as `device-001`. The backend can operate all lab devices. ACL checks authorize topics; they do not validate JSON or prove that a payload's identity is truthful. The CLI checks below must use a direction permitted for that username. Add to `.gitignore` (preserve any existing rules): diff --git a/lessons/02-go-connection.md b/teaching/course-source/02-go-connection.md similarity index 89% rename from lessons/02-go-connection.md rename to teaching/course-source/02-go-connection.md index 9d25559..6991b17 100644 --- a/lessons/02-go-connection.md +++ b/teaching/course-source/02-go-connection.md @@ -37,7 +37,7 @@ Run every shell command in this lesson from the learning project root: cd /home/ryan/Media/Code/Code/robot-market/mqtt-5-learning ``` -Paths such as `cmd/backend/main.go` are relative to this directory. Markdown files under `lessons/` explain the work; create the application files at the paths shown, not inside `lessons/` or `references/`. Code blocks labeled Go, TOML, YAML, or conf are file contents; shell blocks are terminal commands. Create parent directories before saving files. +Paths such as `cmd/backend/main.go` are relative to the repository root. The Markdown implementation lessons are under `teaching/course-source/`; create application files at the paths shown, not inside `teaching/course-source/` or `references/`. Code blocks labeled Go, TOML, YAML, or conf are file contents; shell blocks are terminal commands. Create parent directories before saving files. ## 🔌 1. Build the connection program @@ -142,6 +142,22 @@ func run() error { } ``` +### 🔍 `cmd/backend/main.go` — file overview + +This file is the first executable backend. It creates the process lifecycle, validates connection inputs, starts AutoPaho, waits for a successful CONNACK, and disconnects within a deadline. It does not subscribe to devices or run a business use case yet. + +#### 🚪 `main` — report process failure + +**Summary:** Calls `run` and exits with a nonzero status if setup or runtime returns an error. + +**Details:** `main` keeps process-level error handling out of the connection logic. It logs the returned error and calls `os.Exit(1)` only after `run` has returned; do not move `os.Exit` into a callback, because that would bypass deferred cleanup. + +#### ⚙️ `run` — own the connection lifecycle + +**Summary:** Loads credentials, configures AutoPaho, waits for the first connection, then waits for shutdown. + +**Details:** The signal context owns the manager lifetime. A separate startup context bounds `AwaitConnection`, while a fresh shutdown context gives `Disconnect` time after the signal context has already been cancelled. The callbacks report connection events; they do not make the application ready for device work. Lesson 4 later adds subscriptions and a receive loop. + ## 🔌 2. Understand the connection lifecycle diff --git a/lessons/03-typed-configuration.md b/teaching/course-source/03-typed-configuration.md similarity index 87% rename from lessons/03-typed-configuration.md rename to teaching/course-source/03-typed-configuration.md index 4ef9175..a4009bb 100644 --- a/lessons/03-typed-configuration.md +++ b/teaching/course-source/03-typed-configuration.md @@ -32,7 +32,7 @@ Run every shell command in this lesson from the learning project root: cd /home/ryan/Media/Code/Code/robot-market/mqtt-5-learning ``` -Paths such as `cmd/backend/main.go` are relative to this directory. Markdown files under `lessons/` explain the work; create the application files at the paths shown, not inside `lessons/` or `references/`. Code blocks labeled Go, TOML, YAML, or conf are file contents; shell blocks are terminal commands. Create parent directories before saving files. +Paths such as `cmd/backend/main.go` are relative to the repository root. The Markdown implementation lessons are under `teaching/course-source/`; create application files at the paths shown, not inside `teaching/course-source/` or `references/`. Code blocks labeled Go, TOML, YAML, or conf are file contents; shell blocks are terminal commands. Create parent directories before saving files. ## ⚙️ Why configuration changes MQTT behavior @@ -219,8 +219,8 @@ func (c Config) Validate() error { return fmt.Errorf("client ID and credentials are required") } } - 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 { @@ -228,8 +228,43 @@ func (c Config) Validate() error { } return nil } + +// Keep config validation aligned with the topic builder's identifier rules. +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 +} ``` +### 🔍 `config.go` — file overview + +This file converts defaults, TOML, environment overrides, and explicitly supplied flags into one validated `Config`. MQTT startup receives only this effective value, so invalid local settings fail before the application starts network activity. + +#### ⏱️ `ConnectTimeout` — expose the validated duration + +**Summary:** Converts the configured duration string into `time.Duration` for startup code. + +**Details:** `Load` has already validated the string, so this helper can discard the parse error. It does not represent MQTT Keep Alive or Session Expiry; those settings answer different lifecycle questions. + +#### 📥 `Load` — merge and validate configuration + +**Summary:** Parses flags, loads defaults and TOML, applies supported environment and explicit flag overrides, then returns a typed configuration. + +**Details:** The flag set uses `ContinueOnError` so callers receive parse errors. `flags.Visit` identifies only flags the user supplied, avoiding empty defaults overwriting TOML. Password is intentionally TOML-only. `Unmarshal` maps nested koanf keys into `Config`, and `Validate` runs before any caller can start AutoPaho. + +#### ✅ `Validate` — reject unsafe local settings + +**Summary:** Checks URL shape and loopback use, required identities and credentials, device topic-segment safety, and timeout bounds. + +**Details:** Validation prevents malformed or out-of-scope config from reaching the transport. It cannot prove the broker is reachable or that its ACL permits the client; those are runtime checks. + ## ⚙️ 4. Understand effective configuration and precedence @@ -319,7 +354,7 @@ The snippets are edits to Section 2's complete program, not additional standalon 2. Export `TEST_MQTT5_APP_CONNECT_TIMEOUT=6s`; expect `6s`. 3. Pass `-connect-timeout 4s`; expect `4s`. 4. Remove the flag; expect `6s`, proving an absent flag cannot override the environment. -5. Remove `mqtt.password` from a temporary TOML file, try a missing file, invalid URL, zero duration, and device ID `device/001`. +5. Remove `mqtt.password` from a temporary TOML file, try a missing file, invalid URL, zero duration, and device IDs `device/001` and `device!001`. 6. Write a table-driven test using a temporary TOML file and `t.Setenv`. Clear relevant inherited environment values in each test; environment-mutating tests must not use `t.Parallel`. `unset NAME` removes a terminal environment override, revealing the TOML/default layer underneath; it does not change the file. `export NAME=value` sets the environment layer for child processes. A flag is temporary for that invocation. Restore your sample TOML timeout after the experiment so later lessons have a predictable starting point. diff --git a/lessons/04-publish-subscribe-simulator.md b/teaching/course-source/04-publish-subscribe-simulator.md similarity index 73% rename from lessons/04-publish-subscribe-simulator.md rename to teaching/course-source/04-publish-subscribe-simulator.md index 01fdabf..d7d94eb 100644 --- a/lessons/04-publish-subscribe-simulator.md +++ b/teaching/course-source/04-publish-subscribe-simulator.md @@ -45,6 +45,8 @@ sequenceDiagram The broker, backend, and device have distinct setup. Mosquitto owns its listener, password source, and ACL. Each Go process loads its own broker URL, client ID, and username. The backend and simulator use different usernames for their broker permissions; both use the disposable test password from `configs/lab.toml`. The code below first builds the shared transport that connects, subscribes, receives, and publishes; then the two application loops use it. +At the end of this lesson, ping/pong proves the transport path works, but the backend still treats received bytes as demonstrations. Section 5 adds a shared contract; Section 5A turns a validated heartbeat into a device-view update. Keep this order clear: a network callback receives packets, a contract gives them shape, and an application service gives them meaning. + Before each implementation part, use the question there to predict which process or protocol layer is responsible. Follow [Lesson 0](00-system-lifecycle.md) for the full lifecycle map. ## 🗺️ 1. Map the conversation @@ -53,6 +55,8 @@ Before each implementation part, use the question there to predict which process Map the telemetry, command, and response paths first. This identifies which process must subscribe before we write transport code. +The lab has one deliberately small hierarchy: `lab` names the isolated environment, `v1` is the topic-contract version, `devices` is the entity collection, `{deviceID}` scopes device traffic, and the last level names a flow. `v1` versions the route vocabulary; the payload's `schema_version` versions JSON shape, so the two versions can evolve independently. A `TopicName` is one concrete publish destination; a `TopicFilter` is a subscription pattern. The broker accepts `+` and `#` only in filters, so the Go API keeps these concepts as different types. This hierarchy is the learning contract, not a proposed migration of the existing vending backend. + | Topic | Publisher | Subscriber | Example | | --- | --- | --- | --- | | `lab/v1/devices/device-001/telemetry` | Simulator | Backend | Timestamped heartbeat | @@ -113,10 +117,133 @@ A SUBACK proves the subscription request received a result. Topic ACLs still gov The transport helper can share connection lifecycle, subscription handling, message copying, and publishing. Each caller still supplies its configured username, Client ID, topic list, and receive queue. Keeping those identities separate lets the broker apply different permissions and prevents Client ID takeover. +Topic construction belongs in `internal/contracts/topic.go`, which is introduced here and reused by later lessons. Application composition still chooses which flows each process subscribes to; the contract package only builds and parses names and filters. Mosquitto ACLs remain a separate broker authorization layer. + ```sh -mkdir -p internal/mqtt cmd/simulator +mkdir -p internal/contracts internal/mqtt cmd/simulator ``` +Create `internal/contracts/topic.go`: + +```go +package contracts + +import ( + "fmt" + "strings" +) + +type TopicName string +type TopicFilter string +type Kind string + +const ( + KindTelemetry Kind = "telemetry" + KindCommands Kind = "commands" + KindResponses Kind = "responses" + KindSales Kind = "sales" +) + +const topicPrefix = "lab/v1/devices/" + +type Topic struct { + DeviceID string + Kind Kind +} + +func (t TopicName) String() string { return string(t) } +func (f TopicFilter) String() string { return string(f) } + +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 +} + +func DeviceFilter(kind Kind) (TopicFilter, error) { + if !supportedKind(kind) { + return "", fmt.Errorf("unsupported topic kind %q", kind) + } + return TopicFilter(topicPrefix + "+/" + string(kind)), nil +} + +func DeviceSubscription(deviceID string, kind Kind) (TopicFilter, error) { + topic, err := DeviceTopic(deviceID, kind) + if err != nil { + return "", err + } + return TopicFilter(topic.String()), nil +} + +func ParseTopic(value string) (Topic, error) { + 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 + } +} + +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 +} +``` + +### 🔍 `topic.go` — file overview + +This file is the source of truth for the lab's MQTT routing shape. `DeviceTopic` creates a concrete publish name; `DeviceFilter` creates a fleet-wide per-flow subscription filter; `DeviceSubscription` creates an exact per-device filter; and `ParseTopic` turns an incoming concrete topic into routing data. `TopicName` and `TopicFilter` are distinct string types so code must consciously convert them at the Paho boundary. + +#### 🧱 `DeviceTopic` — build one publish destination + +**Summary:** Validates the device ID and flow kind, then returns a topic with no wildcard levels. + +**Details:** Device IDs must fit one topic level and may contain only ASCII letters, digits, dot, underscore, and hyphen. This avoids slash injection and prevents a wildcard from accidentally becoming part of a publish name. It is a shape check, not proof that a device is authorized to publish there. + +#### 🔎 `DeviceFilter` and `DeviceSubscription` — choose filter scope + +**Summary:** `DeviceFilter` subscribes to one flow across devices; `DeviceSubscription` subscribes to one exact device and flow. + +**Details:** These functions control wildcard placement instead of letting callers concatenate arbitrary strings. The backend uses fleet filters where it must ingest multiple devices; the simulator uses an exact device subscription for least privilege. Mosquitto ACLs still decide whether either role may subscribe. + +#### 🧭 `ParseTopic` — extract route identity + +**Summary:** Checks the shared `topicPrefix`, then validates the remaining device ID and flow kind. + +**Details:** `ParseTopic` first requires the incoming name to start with `topicPrefix`, then splits only the suffix into exactly two levels. The parser does not repeat the prefix's individual segments, so changing the constant updates builders and parsing together. The returned device ID is routing metadata to compare with the payload claim; it does not authenticate the publishing client. + +#### ✅ `supportedKind` and `validID` — share the contract rules + +**Summary:** Centralize the allowed final segments and safe single-level identifier syntax. + +**Details:** Both building and parsing use these rules, preventing the producer and consumer from quietly accepting different topic vocabularies. + Create `internal/mqtt/client.go`: ```go @@ -130,6 +257,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" ) @@ -142,7 +270,7 @@ type Message struct { // Start creates the MQTT connection, subscribes to the requested topics, and // waits until the initial connection and subscriptions are ready. -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) { // Parse the configured broker URL before creating the reconnecting client. broker, err := url.Parse(cfg.MQTT.URL) if err != nil { @@ -164,7 +292,7 @@ func Start(ctx context.Context, cfg config.Config, topics []string, inbox chan<- defer cancel() 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}) } // Ask for QoS 1 and preserve RETAIN so consumers can identify stored data. ack, subErr := cm.Subscribe(subCtx, &paho.Subscribe{Subscriptions: subscriptions}) @@ -237,12 +365,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 { // Publishing waits only for this bounded operation, not for device completion. operation, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() ack, err := manager.Publish(operation, &paho.Publish{ - Topic: topic, QoS: 1, Retain: false, Payload: payload, + Topic: topic.String(), QoS: 1, Retain: false, Payload: payload, Properties: &paho.PublishProperties{ContentType: "application/json"}, }) if err != nil { @@ -268,6 +396,28 @@ func Stop(manager *autopaho.ConnectionManager) { } ``` +### 🔍 `internal/mqtt/client.go` — file overview + +This file is the transport adapter shared by the backend and simulator. It starts AutoPaho, restores subscriptions after connection-up, copies received packet bytes into an application-owned inbox, publishes QoS 1 messages with a deadline, and disconnects cleanly. It does not validate vending contracts or decide what a device message means. + +#### 🔌 `Start` — connect and establish subscriptions + +**Summary:** Creates the connection manager, subscribes to the requested filters, checks each SUBACK reason, and waits for initial readiness. + +**Details:** Connection-up can happen again after reconnect, so subscription setup belongs in that callback. The initial readiness channel lets the caller distinguish CONNACK from successful subscriptions. The publish-receive callback copies the payload before it leaves the packet context and uses a nonblocking inbox send; if the bounded inbox is full, this early example drops the message and logs the loss. + +#### 📤 `Publish` — publish one bounded operation + +**Summary:** Sends a QoS 1 JSON message and returns only after the publish operation reports its broker acknowledgement or an error. + +**Details:** The operation context bounds the wait. A successful acknowledgement covers this publish hop; it does not prove a subscriber received, validated, or applied the message. Timeout can leave the delivery outcome uncertain. + +#### 🛑 `Stop` — disconnect within a deadline + +**Summary:** Gives AutoPaho a fresh, bounded shutdown context and logs a disconnect error. + +**Details:** The fresh context remains usable after the signal context has been cancelled. This stops transport lifecycle work; it does not drain a future application worker queue, which Section 6 adds. + ## 🔗 3. Map the helper to MQTT behavior @@ -316,7 +466,7 @@ The JSON field tags set the wire names, so the payload bytes represent: {"id":"ping-123-1","command":"ping"} ``` -The backend chooses the concrete topic `lab/v1/devices/device-001/commands` using its configured device ID. It passes that topic and the JSON bytes to `mqtt.Publish`; that helper builds a Paho `paho.Publish` with **QoS 1**, **Retain false**, and Content Type `application/json`, then calls AutoPaho's `manager.Publish`. +The backend asks `contracts.DeviceTopic(deviceID, contracts.KindCommands)` for the concrete destination. That constructor validates the device ID as one safe topic level. It passes the resulting `TopicName` and JSON bytes to `mqtt.Publish`; that helper builds a Paho `paho.Publish` with **QoS 1**, **Retain false**, and Content Type `application/json`, then calls AutoPaho's `manager.Publish`. `manager.Publish` waits for the QoS 1 publish exchange or an error/timeout. When it receives a successful `PUBACK`, the helper logs the reason code and returns nil. That confirms the backend-to-broker QoS exchange. A success reason of `0x10` means the broker had no matching subscribers; it is still a successful MQTT acknowledgement, so it does not prove the simulator received anything. A timeout can also leave the outcome uncertain. @@ -346,6 +496,7 @@ import ( "time" "example.com/mqtt5-learning/internal/config" + "example.com/mqtt5-learning/internal/contracts" mqtt "example.com/mqtt5-learning/internal/mqtt" ) @@ -365,10 +516,15 @@ func run() error { } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - // Build this device's topic namespace and bounded receive queue. - base := "lab/v1/devices/" + cfg.MQTT.DeviceID + // Build subscription filters and a concrete command destination. + telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry) + if err != nil { return err } + responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses) + if err != nil { return err } + commandTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindCommands) + if err != nil { return err } inbox := make(chan mqtt.Message, 16) - manager, err := mqtt.Start(ctx, cfg, []string{base + "/telemetry", base + "/responses"}, inbox) + manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{telemetryFilter, responsesFilter}, inbox) if err != nil { return err } @@ -395,7 +551,7 @@ func run() error { if err != nil { return err } - if err := mqtt.Publish(ctx, manager, base+"/commands", payload); err != nil { + if err := mqtt.Publish(ctx, manager, commandTopic, payload); err != nil { log.Printf("command publication failed or outcome uncertain: %v", err) } } @@ -403,6 +559,12 @@ func run() error { } ``` +#### 🧭 `main` and `run` — assemble the backend process + +**Summary:** `main` turns a returned error into a failing process; `run` loads settings, starts MQTT, then handles inbound messages and periodic pings until cancellation. + +**Details:** `main` is intentionally small so startup errors have one exit path. `run` owns resources in reverse order: it creates the signal context, starts the manager, and defers shutdown; it also stops the ticker. Its `select` keeps messages, timer work, and cancellation responsive. A publish error is logged as uncertain because the client may not know whether the broker accepted the packet. + The ticker sends one ping every ten seconds. It starts after the backend's own subscriptions are ready. A timestamp and sequence make IDs distinct enough for this demonstration, not a production idempotency system. The backend logs the response but does not yet implement pending requests or validate completion; Section 7 adds those semantics. @@ -427,6 +589,7 @@ import ( "time" "example.com/mqtt5-learning/internal/config" + "example.com/mqtt5-learning/internal/contracts" mqtt "example.com/mqtt5-learning/internal/mqtt" ) @@ -446,10 +609,15 @@ func run() error { } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - // Build this device's topic namespace and bounded receive queue. - base := "lab/v1/devices/" + cfg.MQTT.DeviceID + // Build one command filter and this device's concrete publish destinations. + 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 } + responseTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindResponses) + if err != nil { return err } inbox := make(chan mqtt.Message, 16) - manager, err := mqtt.Start(ctx, cfg, []string{base + "/commands"}, inbox) + manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{commandFilter}, inbox) if err != nil { return err } @@ -470,7 +638,7 @@ func run() error { if err != nil { return err } - if err := mqtt.Publish(ctx, manager, base+"/telemetry", payload); err != nil { + if err := mqtt.Publish(ctx, manager, telemetryTopic, payload); err != nil { log.Printf("heartbeat: %v", err) } case message := <-inbox: @@ -496,7 +664,7 @@ func run() error { if err != nil { return err } - if err := mqtt.Publish(ctx, manager, base+"/responses", response); err != nil { + if err := mqtt.Publish(ctx, manager, responseTopic, response); err != nil { log.Printf("response: %v", err) } } @@ -504,6 +672,12 @@ func run() error { } ``` +#### 🧭 `main` and `run` — simulate a vending device + +**Summary:** `main` reports process failure; `run` publishes a heartbeat every three seconds and handles commands from the device's command topic. + +**Details:** The simulator uses its own identity and subscribes only to its command namespace. The receive callback hands messages to `inbox`; the main loop validates and responds outside that callback. Cancellation returns cleanly and deferred cleanup stops the ticker and MQTT manager. This simulator is a learning stand-in, not a real vending device driver. + `json.Marshal` serializes Go structs to JSON bytes. Tags choose field names. `json.Unmarshal(message.Payload, &command)` needs a pointer so the decoder can fill the struct. The simulator's processing loop publishes responses outside the receive callback, keeping the callback short and avoiding blocking it on a network round trip. diff --git a/lessons/05-contracts-validation.md b/teaching/course-source/05-contracts-validation.md similarity index 70% rename from lessons/05-contracts-validation.md rename to teaching/course-source/05-contracts-validation.md index e14f53e..2848c52 100644 --- a/lessons/05-contracts-validation.md +++ b/teaching/course-source/05-contracts-validation.md @@ -20,7 +20,7 @@ flowchart LR Prerequisite: Section 4 exchanges heartbeats and ping/pong. The clients share the disposable password in `configs/lab.toml`; select the simulator account with `-username simulator` when starting it. -Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Save the complete code blocks at their project-relative paths, not under `lessons/`. This lesson provides a full working implementation with the pinned Paho v0.23.0 dependency; use the exercises to inspect and verify its behavior. +Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Save the complete code blocks at their project-relative paths, not under `teaching/course-source/`. This lesson provides a full working implementation with the pinned Paho v0.23.0 dependency; use the exercises to inspect and verify its behavior. ## 📨 Follow one message through the trust boundaries @@ -78,7 +78,7 @@ Send a valid heartbeat, then change only its body device ID, then change only it > **❓ Question:** **What shared vocabulary lets the backend and device agree on the shape and meaning of each message?** -The contract package gives both programs the same field names and meanings. For example, if the simulator sends `device` while the backend expects `device_id`, the message may be rejected or misunderstood. +The contract package gives both programs the same field names and meanings. Its `topic.go` file owns the hierarchy and distinguishes a concrete publish name from a subscription filter; `message.go` owns JSON payload types and their checks. For example, if the simulator sends `device` while the backend expects `device_id`, the message may be rejected or misunderstood. ```sh mkdir -p internal/contracts @@ -86,7 +86,11 @@ mkdir -p internal/contracts The complete implementation for this step is shown below. Save each block at its stated path, then update the backend and simulator to use these contracts before they handle or publish messages. -Save the first block as `internal/contracts/messages.go`. It defines the versioned messages, topic parser, and value checks. A command ID remains stable across retries; an MQTT packet identifier identifies only one protocol exchange. +Save this as `internal/contracts/topic.go`. Section 4 introduced its basic purpose; here it is included in full so this lesson can be used on its own. + +### 🔍 `topic.go` — file overview + +This file owns the lab hierarchy `lab/v1/devices/{deviceID}/{kind}`. It builds concrete `TopicName` values for PUBLISH, fleet-wide and exact-device `TopicFilter` values for SUBSCRIBE, and parses an incoming name into a device ID and flow kind. Composition code still chooses the role's subscriptions, and Mosquitto ACLs still authorize them. ```go package contracts @@ -94,12 +98,10 @@ package contracts import ( "fmt" "strings" - "time" ) -const SchemaVersion = 1 - -// Kind values match the final topic segment used to route each message. +type TopicName string +type TopicFilter string type Kind string const ( @@ -109,6 +111,106 @@ const ( KindSales Kind = "sales" ) +const topicPrefix = "lab/v1/devices/" + +type Topic struct { + DeviceID string + Kind Kind +} + +func (t TopicName) String() string { return string(t) } +func (f TopicFilter) String() string { return string(f) } + +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 +} + +func DeviceFilter(kind Kind) (TopicFilter, error) { + if !supportedKind(kind) { + return "", fmt.Errorf("unsupported topic kind %q", kind) + } + return TopicFilter(topicPrefix + "+/" + string(kind)), nil +} + +func DeviceSubscription(deviceID string, kind Kind) (TopicFilter, error) { + topic, err := DeviceTopic(deviceID, kind) + if err != nil { + return "", err + } + return TopicFilter(topic.String()), nil +} + +func ParseTopic(value string) (Topic, error) { + 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 + } +} + +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 +} +``` + +#### 🧱 `DeviceTopic`, `DeviceFilter`, and `DeviceSubscription` — build the right kind of path + +**Summary:** Build a concrete publish name, a fleet-wide flow filter, or an exact per-device subscription filter. + +**Details:** `DeviceTopic` never inserts wildcard characters. `DeviceFilter` places one `+` wildcard at the device level for a known flow; `DeviceSubscription` reuses the validated name to produce an exact filter. The backend uses fleet-wide filters, while the simulator subscribes only to its configured device. These Go types reduce accidental mixing; broker ACLs remain the authorization boundary. + +#### 🧭 `ParseTopic` — recover routing data + +**Summary:** Check the shared `topicPrefix`, then validate the remaining device ID and flow kind. + +**Details:** `ParseTopic` first requires the incoming name to start with `topicPrefix`, then splits only the suffix into exactly two levels. The parser does not repeat the prefix's individual segments, so changing the constant updates builders and parsing together. Parsing establishes which contract to decode and lets the application compare topic identity with payload identity. It does not prove which client published the message. + +Save the next block as `internal/contracts/message.go`. This file defines the versioned payloads and value checks. Topic hierarchy and parsing live in `topic.go`. A command ID remains stable across retries; an MQTT packet identifier identifies only one protocol exchange. + +### 🔍 `message.go` — file overview + +This file defines JSON payload types and their validation rules. Its validation functions check the relationship between parsed topic data and the body before a decoded value can reach a handler. + +```go +package contracts + +import ( + "fmt" + "time" +) + +const SchemaVersion = 1 + // These types define the JSON fields shared by backend and simulator. type Heartbeat struct { SchemaVersion int `json:"schema_version"` @@ -151,19 +253,6 @@ 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 { @@ -243,28 +332,20 @@ 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) - } -} - -// Topic is the parsed device scope and message kind from an MQTT topic. -type Topic struct { - DeviceID string - Kind Kind -} ``` +#### 🪪 `validateEnvelope` — check shared payload fields + +**Summary:** Apply identifier syntax, schema-version, topic/body device identity, and timestamp checks used by several message types. + +**Details:** `validateEnvelope` checks fields shared by all payload contracts and takes `now` as an argument, allowing deterministic time validation. Topic-segment ID syntax is owned by `topic.go`. Neither helper authenticates the MQTT publisher; broker credentials and ACLs perform a separate job. + +#### ✅ Each `Validate` method — check one contract's rules + +**Summary:** Enforce that the JSON type matches the topic kind and that message-specific values are supported. + +**Details:** `Heartbeat.Validate` checks optional temperature bounds; `Command.Validate` accepts only the current harmless `ping`; `CommandResult.Validate` permits known result states; `SalesReport.Validate` checks item count and each SKU, quantity, and amount. The methods are called by `Decode`; handlers should not bypass them. + `internal/contracts/decode.go` bounds the payload before decoding, rejects unknown fields, requires the JSON value to consume the entire input, and calls the decoded contract's `Validate` method before returning it. `MQTTMessage` restricts the generic decoder to types with that method. The parsed topic and clock are explicit inputs so validation can check route/body identity and remain deterministic in tests. ```go @@ -280,11 +361,6 @@ import ( const MaxPayloadBytes = 16 * 1024 -// MQTTMessage is implemented by each versioned message contract. -type MQTTMessage interface { - Validate(topic Topic, now time.Time) error -} - // Decode bounds input, rejects unknown fields, requires one JSON value, // and validates it against the topic before returning it. func Decode[T MQTTMessage](payload []byte, topic Topic, now time.Time) (T, error) { @@ -313,6 +389,16 @@ func Decode[T MQTTMessage](payload []byte, topic Topic, now time.Time) (T, error } ``` +### 🔍 `decode.go` — file overview and function guide + +This file performs bounded JSON decoding and contract validation. `MQTTMessage` is the type constraint: only message types with a `Validate` method can be passed to `Decode`. + +#### 📥 `Decode` — accept one valid contract value + +**Summary:** Enforces the byte limit, decodes exactly one JSON object, rejects unknown fields, then calls the selected type's validator. + +**Details:** The second decoder call distinguishes trailing whitespace from a second JSON value or malformed trailing bytes. The function returns no validated value until every structural and business check succeeds. This protects application state from malformed data, but a valid value still needs an application service to decide what to do with it; Section 5A adds that missing step. + `internal/contracts/decode_test.go` checks malformed boundaries without a broker: ```go @@ -371,7 +457,7 @@ func TestCommandRejectsTopicIdentityMismatch(t *testing.T) { > **❓ Question:** **A message arrives with both a topic and a payload. Why decide which device and message kind the topic represents before running a handler?** -`contracts.ParseTopic` in `messages.go` accepts exactly `lab/v1/devices/{deviceID}/{kind}`. It rejects empty IDs, extra segments, wildcard characters, and unknown message kinds. Call it before decoding so the validator can compare the route's device ID with the payload claim. Add the sales ACL rules only when you publish sales reports. +`contracts.ParseTopic` in `topic.go` accepts exactly `lab/v1/devices/{deviceID}/{kind}`. It rejects unsafe IDs, extra segments, wildcard characters, and unknown message kinds. Use it before decoding so the validator can compare the route's device ID with the payload claim. Add the sales ACL rules only when you publish sales reports. ## ✅ 3. Decode and validate @@ -440,9 +526,17 @@ func run() error { } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry) + if err != nil { return fmt.Errorf("build telemetry filter: %w", err) } + responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses) + if err != nil { return fmt.Errorf("build responses filter: %w", err) } + salesFilter, err := contracts.DeviceFilter(contracts.KindSales) + if err != nil { return fmt.Errorf("build sales filter: %w", err) } + commandTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindCommands) + if err != nil { return fmt.Errorf("build command topic: %w", err) } // Start subscriptions before handling any delivered message. inbox := make(chan mqtt.Message, 16) - manager, err := mqtt.Start(ctx, cfg, []string{"lab/v1/devices/+/telemetry", "lab/v1/devices/+/responses", "lab/v1/devices/+/sales"}, inbox) + manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{telemetryFilter, responsesFilter, salesFilter}, inbox) if err != nil { return fmt.Errorf("start MQTT: %w", err) } @@ -467,7 +561,7 @@ func run() error { if err != nil { return fmt.Errorf("encode command: %w", err) } - if err := mqtt.Publish(ctx, manager, "lab/v1/devices/"+cfg.MQTT.DeviceID+"/commands", payload); err != nil { + if err := mqtt.Publish(ctx, manager, commandTopic, payload); err != nil { log.Printf("command publish failed or uncertain id=%s: %v", id, err) } } @@ -501,6 +595,12 @@ func handleMessage(message mqtt.Message) error { } ``` +#### 🧭 `main`, `run`, and `handleMessage` — validate backend traffic + +**Summary:** `main` reports startup failure, `run` owns the connection and receive loop, and `handleMessage` routes each topic kind to its typed decoder. + +**Details:** The receive loop logs rejected messages without crashing the process. `handleMessage` parses the topic before decoding; that parsed kind chooses the expected payload type, and `Decode` checks the body against the route. The default branch refuses kinds the backend does not consume. At this stage successful sales and results are only logged; Section 5A creates application state, while Sections 7 and 13 add matching and durable sales storage. + `cmd/simulator/main.go`: ```go @@ -531,10 +631,15 @@ func run() error { if err != nil { return fmt.Errorf("load config: %w", err) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - // Publish this device's heartbeat and subscribe only to its command topic. - base := "lab/v1/devices/" + cfg.MQTT.DeviceID + // Construct routes before connecting so invalid identity fails at startup. + 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 } + responseTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindResponses) + if err != nil { return err } inbox := make(chan mqtt.Message, 16) - manager, err := mqtt.Start(ctx, cfg, []string{base+"/commands"}, inbox) + manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{commandFilter}, inbox) if err != nil { return fmt.Errorf("start MQTT: %w", err) } defer mqtt.Stop(manager) ticker := time.NewTicker(3 * time.Second) @@ -549,7 +654,7 @@ func run() error { heartbeat := contracts.Heartbeat{SchemaVersion: contracts.SchemaVersion, MessageID: fmt.Sprintf("heartbeat-%d-%d", time.Now().Unix(), sequence), DeviceID: cfg.MQTT.DeviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339)} payload, err := json.Marshal(heartbeat) if err != nil { return fmt.Errorf("encode heartbeat: %w", err) } - if err := mqtt.Publish(ctx, manager, base+"/telemetry", payload); err != nil { log.Printf("heartbeat publish: %v", err) } + if err := mqtt.Publish(ctx, manager, telemetryTopic, payload); err != nil { log.Printf("heartbeat publish: %v", err) } case message := <-inbox: if message.Retain { log.Printf("reject retained command topic=%q", message.Topic); continue } if err := respond(ctx, manager, cfg.MQTT.DeviceID, message); err != nil { log.Printf("reject command topic=%q reason=%v", message.Topic, err) } @@ -567,10 +672,25 @@ func respond(ctx context.Context, manager *autopaho.ConnectionManager, deviceID result := contracts.CommandResult{SchemaVersion: contracts.SchemaVersion, MessageID: "result-"+command.CommandID, DeviceID: deviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: command.CommandID, Status: "completed"} payload, err := json.Marshal(result) if err != nil { return fmt.Errorf("encode result: %w", err) } - return mqtt.Publish(ctx, manager, "lab/v1/devices/"+deviceID+"/responses", payload) + responseTopic, err := contracts.DeviceTopic(deviceID, contracts.KindResponses) + if err != nil { return fmt.Errorf("build response topic: %w", err) } + return mqtt.Publish(ctx, manager, responseTopic, payload) } ``` +#### 📟 `respond` — validate a command and publish its result + +**Summary:** Parses the command topic, validates its payload, confirms the target device, then publishes a completed result. + +**Details:** Each check returns before result construction if the topic or body is invalid. The simulator only demonstrates a harmless ping here; it does not execute a real vending-machine operation. Section 7 adds retained-message rejection, response-topic authorization, Correlation Data, and duplicate suppression. + +#### 🧭 `main` and `run` — operate the simulator + +**Summary:** `main` turns failures into a nonzero exit; `run` loads the simulator identity, subscribes to commands, publishes heartbeats, and calls `respond` for each inbound packet. + +**Details:** `run` owns the signal context, manager, ticker, and receive loop. It keeps network callback work separate from contract validation and result publication. The heartbeat ticker is independent of command reception, so an idle device still reports recent contact. Section 6 moves potentially slow message work into bounded workers. + + ```sh gofmt -w internal/contracts cmd @@ -591,4 +711,4 @@ The supplied tests cover topic parsing, malformed JSON boundaries, payload size, The backend and simulator now have a shared vocabulary for heartbeats, commands, results, and sales reports. When a message arrives, the receiver checks its topic, size, JSON shape, schema version, identifiers, timestamp, and topic/body device match before changing application state. A broker-routed message can still be rejected by the application; successful MQTT delivery alone does not make its contents valid or trusted. -Previous: [04-publish-subscribe-simulator.md](04-publish-subscribe-simulator.md). Next: [06-concurrency-cancellation.md](06-concurrency-cancellation.md). +Previous: [04-publish-subscribe-simulator.md](04-publish-subscribe-simulator.md). Next: [05A — Backend message processing](05a-backend-message-processing.md). diff --git a/teaching/course-source/05a-backend-message-processing.md b/teaching/course-source/05a-backend-message-processing.md new file mode 100644 index 0000000..f34cb7c --- /dev/null +++ b/teaching/course-source/05a-backend-message-processing.md @@ -0,0 +1,352 @@ +# 🧭 Section 5A — Turn a valid message into backend state + +## 🎬 Start with the system story + +> **❓ Question:** **The backend received a valid heartbeat. What changes in the application, and how can another part of the backend ask whether the device was recently heard from?** + +Section 5 proves that a packet has a valid topic and payload. That is a trust boundary, not a business action. A backend still needs an application service that decides what the valid message means, a place to record the result, and a safe way to report the outcome. + +```mermaid +flowchart LR + B[Mosquitto delivers PUBLISH] --> R[Receive loop] + R --> V[Parse topic and validate contract] + V -->|valid heartbeat| S[Application service] + S --> M[In-memory device view] + S --> L[Structured application log] + M --> Q[Status query: recent heartbeat?] + V -->|invalid| E[Log bounded rejection; no state change] +``` + +The exercise adds one small vertical slice: a valid heartbeat updates the backend's last-seen view. It uses an in-memory store so the data flow is visible before concurrency and PostgreSQL arrive. A fresh heartbeat is evidence of recent application traffic; it is not proof that the MQTT connection is currently up or that every part of the vending machine is healthy. + +**Before coding, predict:** Which facts come from the broker, which come from validation, and which are created by the application? A topic and schema-valid body are not yet a stored status, and a `PUBACK` is not evidence that this store was updated. + +Prerequisite: [Section 4](04-publish-subscribe-simulator.md) introduced the complete `internal/contracts/topic.go` hierarchy: `DeviceTopic` builds concrete names, `DeviceFilter` builds fleet filters, `DeviceSubscription` builds exact-device filters, and `ParseTopic` extracts the route. [Section 5](05-contracts-validation.md) adds `contracts.Decode` and `contracts.Heartbeat`. The code below assumes the Paho message-copying receive helper and `mqtt.Message` from Section 4; topics must use the shared contract rather than hand-built strings. + +## 🧩 1. Separate validation from application meaning + +> **❓ Question:** **Why should the callback not also decide what “online” means and update shared device state?** + +The callback owns packet delivery. The application service owns the meaning of a validated message. Keeping those responsibilities separate lets the backend test status behavior without a broker and later move the service behind Section 6's worker pool. + +Trace one heartbeat: + +```text +PUBLISH → callback copies topic and bytes → inbox + → service parses topic → Decode validates contract + → store updates last-seen snapshot → structured log records the outcome +``` + +The service gives each existing contract an honest current behavior: heartbeats update a last-seen view, while sales reports and command results are validated and logged but not yet persisted or correlated. Section 7 adds command matching; Section 13 adds durable sales storage; Section 12 adds bounded device-log and richer health workflows. This boundary prevents a successful decode from being presented as completed business work. + +## 🗃️ 2. Create a small in-memory device view + +> **❓ Question:** **Which timestamp should control freshness: when the device says it observed the heartbeat, or when this backend received it?** + +Store both. Use the device timestamp to avoid replacing a newer report with an older delayed one. Use the backend receive timestamp to answer whether a heartbeat arrived recently; the device clock does not decide when this backend last heard from it. + +Save this complete file as `internal/deviceview/store.go`. + +### 🔍 `store.go` — file overview + +This file stores the newest accepted heartbeat per device and derives a freshness view. The map is protected because Section 6 will call the application service from multiple workers. The store is still volatile: a process restart clears every snapshot. + +```go +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 +} +``` + +#### 🏗️ `NewStore` — initialize the state owner + +**Summary:** Creates the map that holds one latest snapshot per device. + +**Details:** The map starts empty, so a status query before the first valid heartbeat returns `exists == false`. The store does not invent an offline record for a device the backend has never seen. + +#### 📥 `RecordHeartbeat` — apply a validated observation + +**Summary:** Converts the contract timestamp, copies optional temperature data, and replaces the stored snapshot only when the report is newer. + +**Details:** `Decode` has already checked the timestamp, but the store parses it again because its own method should not silently accept malformed values if another caller uses it later. The device's `OccurredAt` orders reports; `receivedAt` is captured by the backend and supports freshness queries. The mutex protects the map while concurrent workers update it. The temperature is stored by value, and `TemperatureSet` preserves the distinction between a missing reading and a reported zero. This also avoids sharing a mutable pointer with either the incoming message or a status caller. + +If a duplicate or delayed older heartbeat arrives, the method returns `false` and leaves the newer state untouched. This is a small ordering rule, not durable deduplication: after restart the store has no history, and equal timestamps are treated as non-newer. + +#### 🔎 `GetStatus` — query recent application evidence + +**Summary:** Returns the latest snapshot and whether the backend received it within the caller's freshness window. + +**Details:** `HeartbeatFresh` is computed from backend receipt time, not device time. It means “a valid heartbeat arrived recently,” not “the broker connection is up” or “the machine is operational.” A missing snapshot returns `false` as the second result. Since the snapshot contains only values, returning its map copy does not expose mutable store-owned data. + +## 🧠 3. Add the application service + +> **❓ Question:** **Where do topic parsing, payload validation, state updates, and operational logging meet?** + +The service coordinates those steps. It does not own a Paho client or publish MQTT packets; it takes a transport-neutral message value and applies one use case. Save this complete file as `internal/deviceview/service.go`. + +### 🔍 `service.go` — file overview + +This file turns an incoming message into an application decision. Heartbeats update the device view; sales reports and command results are validated and logged until their later persistence/correlation lessons. Invalid topics or payloads return errors before the store changes. Structured logs include identifiers and outcomes, not the raw payload. + +```go +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) +} +``` + +#### 🧰 `NewService` — check application dependencies + +**Summary:** Builds a service only when its store, logger, and freshness policy are configured. + +**Details:** These checks fail at startup instead of allowing a nil pointer panic when the first device message arrives. `freshFor` is an application policy; it is not MQTT Keep Alive or Session Expiry. Choose a value that accounts for the simulator's heartbeat interval and expected delay. + +#### 🔄 `HandleMessage` — validate, route, apply, and record the result + +**Summary:** Checks cancellation, parses the topic, then validates and routes each supported contract to its current application behavior. + +**Details:** For telemetry, each early return happens before the store update, so malformed messages cannot alter status. The service captures one backend receipt time and reuses it for validation, storage, and logging. The store's `false` result represents an older or duplicate report; it is a handled condition, not a transport failure. For sales and responses, successful decoding is logged as validation only: no sale is saved and no command is matched yet. The logger records stable identifiers but avoids printing untrusted payload contents. + +This is an application outcome. The MQTT receive callback may already have returned and automatic acknowledgement may already have occurred. Returning an error here does not reverse a `PUBACK`; Section 6 makes that boundary measurable under worker load. + +#### 🔎 `Status` — expose the current device view to callers + +**Summary:** Queries the store and evaluates heartbeat freshness using the service's clock and configured window. + +**Details:** The method returns the latest snapshot only if one exists. It does not issue an MQTT request to the device. A future API can expose this method; Section 8 builds HTTP endpoints, while Sections 10 and 12 add richer presence and vending-health semantics. + +## 🔌 4. Connect the service to the receive loop + +> **❓ Question:** **What changes when validated messages now update application state instead of only producing a “valid message” log line?** + +Create one store and service during process startup, then call the service for telemetry messages from the existing backend message switch: + +Add these imports to `cmd/backend/main.go`: + +```go +"log/slog" +"time" + +"example.com/mqtt5-learning/internal/deviceview" +``` + +```go +// In run(), after config.Load and before the receive loop starts: +store := deviceview.NewStore() +deviceView, err := deviceview.NewService(store, slog.Default(), 90*time.Second) +if err != nil { + return fmt.Errorf("create device-view service: %w", err) +} + +// Replace the old validation-only handler with this service call. +func handleMessage(ctx context.Context, deviceView *deviceview.Service, message mqtt.Message) error { + return deviceView.HandleMessage(ctx, message) +} +``` + +#### 🔌 `run` and `handleMessage` — connect startup to application work + +**Summary:** `run` constructs one shared store/service at startup; `handleMessage` delegates each received message to that service. + +**Details:** Create the service after configuration loads and before the receive loop starts. Replace the validation-only handler with the shown `handleMessage` function and update the receive loop to pass its context and shared service. The service updates heartbeat state and validates/logs sales and responses for now; Section 7 adds response matching and Section 13 adds sales persistence. + +Use this receive-loop call and function signature to make the required data flow explicit: + +```go +case message := <-inbox: + if err := handleMessage(ctx, deviceView, message); err != nil { + slog.Error("message handling failed", "topic", message.Topic, "error", err) + } +``` + +```go +func handleMessage(ctx context.Context, deviceView *deviceview.Service, message mqtt.Message) error { + return deviceView.HandleMessage(ctx, message) +} +``` + +### 🧪 Observe the application result + +1. Start the lab backend and simulator. Confirm structured `heartbeat recorded` events include the device and message IDs but no raw JSON payload. +2. Query `deviceView.Status("device-001")` from a temporary diagnostic call. After a heartbeat, expect a snapshot and `HeartbeatFresh == true`: + + ```go + status, exists := deviceView.Status("device-001") + if exists { + slog.Info("device view", "last_seen", status.Snapshot.ReceivedAt, "heartbeat_fresh", status.HeartbeatFresh) + } + ``` +3. Stop the simulator, wait longer than the 90-second example window, and query again. The snapshot remains, but freshness becomes false. No MQTT packet declared the device offline; the backend inferred staleness from elapsed time. +4. Deliver an older `occurred_at` after a newer heartbeat. The service logs that it ignored the older observation, and the stored snapshot does not move backward. + +5. Change the topic/body device IDs or corrupt the payload. Validation returns an error and the stored snapshot remains unchanged. + +Do not confuse backend logs with device diagnostic logs. These `slog` entries explain what the backend did. A device log stream needs its own contract, size/frequency limits, and storage policy; the later vending-workflow lesson covers that. Likewise, a fresh heartbeat proves recent application traffic, not machine health or a live MQTT socket. + +### ✅ Result — what the first five lessons were missing + +You now have the missing application step between “valid packet” and “concurrent worker”: a service applies a specific use case to a validated message, a store exposes the resulting view, and a structured log records the decision. The status is deliberately simple and volatile. Section 6 adds bounded concurrent handling; later lessons add durable records, richer health/log behavior, and real vending workflows. + +**Primary references:** [MQTT 5.0 OASIS Standard](https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html) for the distinction between protocol delivery and application payload handling; [Go `log/slog`](https://pkg.go.dev/log/slog) for structured logging; [Paho Go v0.23.0 API](https://pkg.go.dev/github.com/eclipse/paho.golang/paho@v0.23.0) for the transport API used by the surrounding lesson. + +Previous: [05-contracts-validation.md](05-contracts-validation.md). Next: [06-concurrency-cancellation.md](06-concurrency-cancellation.md). diff --git a/lessons/06-concurrency-cancellation.md b/teaching/course-source/06-concurrency-cancellation.md similarity index 50% rename from lessons/06-concurrency-cancellation.md rename to teaching/course-source/06-concurrency-cancellation.md index 61674b9..7b7e6db 100644 --- a/lessons/06-concurrency-cancellation.md +++ b/teaching/course-source/06-concurrency-cancellation.md @@ -18,7 +18,7 @@ flowchart LR **Before each implementation step, ask:** What work must happen before the MQTT acknowledgement, what can happen afterward, and what is the defined behavior when capacity is exhausted? Follow [Lesson 0](00-system-lifecycle.md) for the broker-to-callback-to-application path. -Prerequisite: Section 5 validates messages before handling them. The backend and simulator continue to read their shared disposable password from `configs/lab.toml`; select each broker username with `-username` when starting the process. +Prerequisite: Section 5 validates messages, and Section 5A applies them through an application service to an in-memory device view. This lesson moves that same service behind workers. The backend and simulator continue to read their shared disposable password from `configs/lab.toml`; select each broker username with `-username` when starting the process. Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Save the complete source blocks at the project-relative paths shown below. This lesson includes the full dispatcher implementation; the exercises ask you to run it and inspect its admission, overload, and shutdown behavior. @@ -60,10 +60,14 @@ this client receives PUBLISH → callback copies into RAM queue → callback ret The worker may finish before or after the ACK. If the process crashes after the ACK but before the effect, the broker can consider its delivery complete while the application loses the work. Increasing channel capacity increases the possible backlog; it does not close that gap. +Read the arrows as two independent delivery hops. The publisher's QoS 1 exchange ends when the broker acknowledges that publisher. The broker then starts a separate delivery to this subscriber, and other subscribers receive their own independent deliveries. The callback's copy puts bytes into process memory; returning from that callback is the event that permits automatic acknowledgement in this example. The worker's later validation and side effect belong to the application lifecycle, not to the completed MQTT exchange. This is why queue admission and business completion need separate evidence and, for important work, separate durable records. + In manual mode, `EnableManualAcknowledgment` means Paho does not automatically acknowledge a QoS 1 message when the callback returns. Your application must call `received.Client.Ack(received.Packet)` to release it. Returning an error alone does not acknowledge or reject it, and does not request a retry. Unless your code calls `Ack`, that delivery stays unacknowledged on the connection. This can occupy an in-flight slot and slow later QoS 1/2 deliveries. For important work, the intended sequence is receive → validate → commit durable admission → call `Ack`. Preserve the receiving client and original packet for this operation; a payload-only copy is insufficient. Check Ack's error and study the pinned source before implementing it. Do not reuse this QoS 1 explanation as a claim that every QoS 2 handshake stage waits for your database. The client's tracker sends acknowledgements in receipt order. If work 3 finishes before 1, acknowledging 3 can remain buffered behind 1. Slow work therefore consumes inflight capacity even with several workers. Manual acknowledgement delays protocol completion; it does not persist your Go queue, remove duplicate effects, or automatically schedule retries while the same connection stays open. +The practical choice is about the promise made to the broker. Automatic acknowledgement is simple, but returning after an in-memory enqueue accepts a possible loss window. Manual acknowledgement lets the application delay completion until its chosen checkpoint, but the checkpoint should be durable if a process crash must not lose work. Because acknowledgements are tracked in receipt order, finishing jobs concurrently does not guarantee that later packets can be acknowledged ahead of an earlier one. Keep that protocol limit in mind when choosing worker parallelism and Receive Maximum. + ### ↩️ Choose what the callback promises | Message class | Reasonable admission choice | Meaning of a successful admission | @@ -74,11 +78,17 @@ The client's tracker sends acknowledgements in receipt order. If work 3 finishes For this in-memory lesson, keep the loss limitation visible. Use a fake acknowledgement recorder to test ordering of admission and acknowledgement without pretending that the fake verifies broker behavior. +Use the table as a policy map, not as a claim that all message types need the same storage. A heartbeat is usually useful as a recent sample, so bounded memory plus a drop counter can be an acceptable contract. A report that drives accounting or inventory needs durable admission before the broker delivery is acknowledged; Section 13 builds that inbox. A command needs identity, expiry/deadline, and duplicate handling before the backend can claim it was accepted. Even then, admission is not device acceptance: the device must return an application-level result. A fake recorder can verify that code calls its acknowledgement hook only after the selected admission step; broker/session behavior still needs an integration experiment. + ### 🧪 Predict the overload Two workers taking 100 ms per item can complete about 20 items/second before other overhead. A burst of 100 items arriving much faster cannot fit into two busy workers plus 16 waiting slots. Predict rejection, then measure actual accepted/completed/rejected counts; scheduler timing affects the exact split. Increasing workers may reorder effects for the same device. If revision order matters, serialize that device's work or enforce revision checks. -Shutdown also has a dependency: if draining work must publish results, keep its MQTT connection alive while draining. Stop new admission first, drain accepted work with a deadline, then disconnect transport and join workers. If you disconnect first, record unsent results for recovery; an in-memory-only lesson cannot promise to deliver them. The signal context in the early example cancels AutoPaho immediately, so a real drain policy needs a separately controlled transport lifetime. +The estimate follows from service time: each worker can process about ten 100 ms jobs per second, so two workers provide roughly twenty per second when work is uniformly sized. With both workers busy and a queue capacity of 16, at most 18 jobs can be active or waiting at once; additional arrivals encounter `Full` until a worker frees capacity. A fast finite burst may therefore produce a different accepted count depending on scheduling and exactly when arrivals occur. The counters tell you what happened in that run. More workers increase throughput but also allow operations for one device to overlap; use per-device serialization or revision checks when order is part of the data contract. + +Shutdown also has a dependency: if draining work must publish results, keep its MQTT connection alive while draining. Stop new admission first, drain accepted work with a deadline, then disconnect transport and join workers. If you disconnect first, record unsent results for recovery; an in-memory-only lesson cannot promise to deliver them. The Section 4 baseline uses one signal context for AutoPaho; the complete Section 6 integration below separates signal and transport lifetimes so it can drain first. + +Treat shutdown as an ordering protocol among producers, queues, workers, and transport. First stop the receive loop from submitting new jobs; then close pool admission. Give workers a bounded interval to finish while MQTT remains available for replies. Only after the drain should that transport be disconnected. If the deadline expires, cancellation asks cooperative handlers to stop, but unfinished in-memory jobs may be lost. Keep signal, drain, and transport lifetimes separate.
❓ Question: What does a callback error do to QoS 1 acknowledgement? @@ -102,13 +112,19 @@ Read `references/paho.golang-v0.23.0/paho/client.go`, especially receive callbac Section 4 copies payloads into a bounded channel and drops messages when full. With automatic acknowledgement, Paho can send PUBACK for this client's delivery after the callback returns—even if the application later discovers that the queue was full. This is a deliberate loss window, not reliable ingestion. +For this source-reading step, follow the packet from the receive handler to callback invocation and then to the acknowledgement decision. Record which condition enables manual mode, whether callback errors change the automatic-ack path, and what `Ack` needs to identify the original delivery. These observations explain why a channel send is an application decision rather than an MQTT acknowledgement. The written notes become a reference for the implementation choices in the next section; do not infer behavior from a method name alone when the pinned client source is available. + ## 🛠️ 2. Build a bounded dispatcher > **❓ Question:** **When messages arrive faster than handlers can finish, where is excess work stored and how does the application report that capacity is exhausted?** Set a finite capacity and make admission outcomes explicit for each message class. -Save this complete file as `internal/dispatch/pool.go`. It provides bounded nonblocking admission, explicit outcomes, worker accounting, and a shutdown deadline. +### 🔍 `pool.go` — file overview + +This file implements one bounded job queue and a fixed set of workers. Callers submit jobs without waiting for free capacity, inspect admission and processing counters, then stop submissions and drain work during shutdown. The pool limits waiting jobs and simultaneous jobs; it does not save them to disk. + +Save this complete file as `internal/dispatch/pool.go`. ```go package dispatch @@ -227,9 +243,74 @@ func (p *Pool) worker() { } ``` -The complete wiring above creates separate pools for traffic classes that must not consume one another's capacity. It submits work from the receive loop and reports `Full` as an explicit application rejection; under automatic MQTT acknowledgement that rejection does not undo the acknowledgement. +#### 🧩 Types at a glance -Save this complete router as `internal/dispatch/receiver.go`. It sends commands and sales to the important-work pool, telemetry to its own pool, and rejects unsupported topic kinds before a worker sees them. +- `Outcome` reports whether a job was `Accepted`, rejected as `Full`, or refused because the pool is `Stopping`. +- `Job` is the function a worker runs. Its context lets the job observe pool cancellation. +- `Stats` reports admission counts separately from worker results: accepted, rejected, completed, and failed. +- `Pool` owns the queue, workers, lifecycle context, synchronization, and counters. + +#### 🚀 `New` — start the pool + +**Summary:** Creates the bounded queue and starts the requested number of workers. + +**Details:** Worker counts below one become one; negative capacity becomes zero. The derived context connects parent cancellation to jobs. `done` closes when every worker exits, allowing shutdown to wait for the whole worker set. A zero-capacity channel admits a job only when a worker can receive it immediately; a positive capacity holds that many waiting jobs. + +#### 📥 `Submit` — attempt immediate admission + +**Summary:** Validates the request and either enqueues the job immediately or returns a reason it was not admitted. + +**Details:** A nil job or already-cancelled caller context returns an error. Under the mutex, the method rejects submissions after shutdown begins. The `select` with a `default` case never waits for queue space: a successful send increments `Accepted`; no available slot increments `Rejected` and returns `Full`. The caller context is checked only before admission; the job itself receives the pool context when a worker runs it. + +#### 🛑 `StopAdmission` — close submissions safely + +**Summary:** Prevents future submissions and closes the job channel once. + +**Details:** The mutex is shared with `Submit`, so the channel cannot be closed between the submitter's state check and send. Workers can still read already-queued jobs after closure. Stop message producers before calling this method so no producer continues trying to submit. + +#### ⏳ `Shutdown` — drain until a deadline + +**Summary:** Stops admission, waits for workers, and cancels jobs if the deadline expires. + +**Details:** When `done` closes first, all workers have exited and shutdown returns nil. If the caller context expires first, the pool cancels the job context and returns that context error. Cancellation is cooperative: a job must check its context or use context-aware I/O. On timeout, workers may abandon queued jobs when they observe cancellation, so this RAM-only queue cannot promise those jobs completed. + +**What does `ctx.Err()` contain during shutdown?** The `ctx` passed to `Pool.Shutdown` is the drain context. If its deadline expires before the workers finish, `ctx.Err()` returns `context.DeadlineExceeded`; if it is canceled for another reason, it returns `context.Canceled`. If all workers finish before cancellation, `Pool.Shutdown` returns `nil` instead of `ctx.Err()`. The pool uses this error as the shutdown result and cancels the worker job context when the wait times out. + +#### 📊 `Stats` — read counters + +**Summary:** Returns a concurrency-safe snapshot of the pool's four counters. + +**Details:** Atomic loads allow worker updates and reads to happen concurrently. `Accepted` is queue admission, not successful processing; `Completed` and `Failed` are outcomes after a worker runs a job. + +#### 👷 `worker` — execute jobs + +**Summary:** Repeatedly receives jobs and records each job's result. + +**Details:** Each worker exits when the pool context is cancelled or the closed queue is drained. A job error increments `Failed`, then that worker continues to the next job. A nil error increments `Completed`. The worker count therefore caps parallel job execution. + +### 🔍 `receiver.go` — file overview + +This file routes each validated topic to the right pool and transfers message ownership safely to a worker. Commands and sales use the important-work pool; telemetry uses its own pool so a telemetry burst cannot fill the important queue. + +#### 🧩 `Processor` and `Receiver` — declare the route + +**Summary:** `Processor` is the business function; `Receiver` holds it and the two pools. + +**Details:** The processor receives the worker context and message. The context supports cooperative shutdown. Separate pool fields make the capacity policy visible at the receiver boundary. + +#### 📬 `Receiver.Submit` — validate, route, and enqueue + +**Summary:** Checks configuration and topic, picks a pool, copies payload bytes, then delegates admission to `Pool.Submit`. + +**Details:** A missing processor, invalid topic, or missing pool returns an error before worker execution. Commands and sales select `Important`; other recognized kinds select `Telemetry`. The payload copy prevents the worker from reading bytes that the receive path may reuse or mutate after the method returns. If the message type later adds other mutable slices, maps, or pointers, those need an ownership decision too. The closure passes the pool's worker context to the processor. + +### 🔌 Process wiring — connect receive and worker paths + +**Summary:** Creates isolated pools, installs the application processor, then submits each received message without doing slow work in the receive loop. + +**Details:** `dispatch.New(context.Background(), 2, 16)` configures two workers and sixteen waiting slots for each traffic class. The independent parent context keeps a signal from cancelling workers before the drain; `Pool.Shutdown` cancels jobs after the drain deadline. The processor closure calls the backend handler; in the simulator it calls `respond(...)`. The `case` receives from `inbox`, calls `receiver.Submit` with the signal context, logs errors, and handles non-`Accepted` outcomes. `Full` is an application admission rejection; logging it does not undo an automatic QoS 1 acknowledgement. A command or sales rejection needs an explicit application policy, and durable admission is required if a process crash must not lose accepted jobs. + +Save this complete router as `internal/dispatch/receiver.go`. It sends commands, command responses, and sales to the important-work pool, telemetry to its own pool, and rejects unsupported topic kinds before a worker sees them. ```go package dispatch @@ -256,7 +337,7 @@ func (r *Receiver) Submit(ctx context.Context, message mqtt.Message) (Outcome, e topic, err := contracts.ParseTopic(message.Topic) if err != nil { return Stopping, err } pool := r.Telemetry - if topic.Kind == contracts.KindCommands || topic.Kind == contracts.KindSales { pool = r.Important } + 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...) @@ -264,15 +345,13 @@ func (r *Receiver) Submit(ctx context.Context, message mqtt.Message) (Outcome, e } ``` -Wire the receiver into each process after creating the pools. The receive loop below keeps the callback path short and reports full queues explicitly: +Wire the receiver into each process after creating the pools. In the backend, use the Section 5A service as the processor. In the simulator, use its command-response handler as the processor. The receive loop below keeps the callback path short and reports full queues explicitly: ```go -// Keep telemetry and important work from consuming each other's queue slots. -telemetryPool := dispatch.New(ctx, 2, 16) -importantPool := dispatch.New(ctx, 2, 16) -receiver := &dispatch.Receiver{Telemetry: telemetryPool, Important: importantPool, Process: func(jobCtx context.Context, message mqtt.Message) error { - return handleMessage(message) // Use respond(...) in the simulator. -}} +// Backend: keep telemetry and important work from consuming each other's queue slots. +telemetryPool := dispatch.New(context.Background(), 2, 16) +importantPool := dispatch.New(context.Background(), 2, 16) +receiver := &dispatch.Receiver{Telemetry: telemetryPool, Important: importantPool, Process: deviceView.HandleMessage} case message := <-inbox: // Report overload locally; an MQTT acknowledgement cannot undo this loss. @@ -281,14 +360,38 @@ case message := <-inbox: if outcome != dispatch.Accepted { log.Printf("message not admitted: outcome=%d topic=%s", outcome, message.Topic) } ``` +In the simulator process, construct the same receiver with a processor that runs the existing command handler using the worker pool's context: + +```go +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) + }, +} +``` + +The simulator subscribes to command topics, which the receiver routes to the important-work pool. The backend routes telemetry, sales, and responses through the Section 5A service until later lessons replace its temporary sales/response behavior. + Choose a policy per class: disposable telemetry may be dropped with a counter; commands and sales must produce an explicit admission failure rather than silently disappear. Admission failure under automatic MQTT acknowledgement still loses application work. Record this limitation until durable admission is introduced in Section 13. Do not claim manual acknowledgement alone makes an in-memory queue durable. +When adapting this wiring, keep the pool limits near process startup so they are visible and configurable. Check the returned error before interpreting the outcome: parse/configuration failures are errors, while queue-full and stopped states are outcomes. Decide and record the policy for each message class; the in-memory/automatic-ACK setup cannot guarantee that a rejected command or sales report will be retried. + ## 🛑 3. Define ownership and shutdown > **❓ Question:** **If a worker is still processing when shutdown begins, which components must stay alive until the work finishes or its deadline expires?** Stop admission before disconnecting MQTT, then let workers finish queued jobs under one shared shutdown deadline. Save this helper as `internal/dispatch/shutdown.go`: +In each complete process listing, the signal case logs and returns from `run`; it does not call the drain itself. The deferred `dispatch.Shutdown` then runs as `run` returns. Since that defer is registered after `defer mqtt.Stop(manager)`, Go's last-in-first-out defer order drains the workers before disconnecting MQTT. + +### 📄 `shutdown.go` — file overview + +This file coordinates shutdown across all dispatch pools. It stops every pool from accepting work before it waits for any pool, so one queue cannot continue growing while another drains. + ```go package dispatch @@ -309,8 +412,16 @@ func Shutdown(ctx context.Context, pools ...*Pool) error { } ``` +#### ⏳ `Shutdown` — stop and drain all pools + +**Summary:** Closes admission on each non-nil pool, then waits for all of them using the same deadline. + +**Details:** The first loop calls `StopAdmission` on every pool before the second loop waits. This prevents a later pool from continuing to accept while an earlier one drains. Each pool receives the same context, so the caller's deadline applies to the whole operation rather than restarting per pool. `errors.Join` returns failures from all pool shutdowns, and nil entries are skipped. Stop producers before calling this helper; keep MQTT connected through the drain if workers need it to publish results. + Copy retained byte slices, including future correlation metadata. Protect shared maps with a mutex or give one goroutine ownership. Never hold a state mutex while publishing over the network. Use operation deadlines derived from the application lifecycle. +🧠 **Why these rules matter:** A slice refers to mutable backing storage, so copy bytes that must outlive the callback. Shared maps need a mutex or a single goroutine owner. A network publish can block, so do not hold a state mutex while publishing. Derive operation deadlines from the application lifecycle so workers cannot wait forever on network or storage calls during shutdown. + On shutdown, stop admitting new work and let accepted work finish within a separate bounded drain context. Keep MQTT available during the drain if workers must publish results, then stop the transport producers. Cancel remaining work when the drain deadline expires. Never close a queue while a producer can still send; waiting for worker completion is part of shutdown. Document what happens to unfinished in-memory work. ## 🔗 4. Integrate and stress it @@ -319,7 +430,9 @@ On shutdown, stop admitting new work and let accepted work finish within a separ Measure accepted, rejected, completed work, queue capacity, and shutdown time under a finite burst. -Use the receiver wiring above in both processes. Keep transport callbacks short, and send validated messages to the appropriate pool. A finite burst generator should stop after its configured count; do not create one goroutine per incoming message. Record accepted, rejected, completed, and failed counts with the configured worker count and queue capacity. +Use the receiver wiring above in both processes, with `deviceView.HandleMessage` for the backend and a processor that calls the simulator's `respond(...)` for commands. Keep transport callbacks short, and send messages to the appropriate pool. A finite burst generator should stop after its configured count; do not create one goroutine per incoming message. Record accepted, rejected, completed, and failed counts with the configured worker count and queue capacity. + +Apply the same dispatcher boundaries in both programs, but connect each to its own business behavior: the backend's handler and the simulator's ping/pong responder. The finite burst should have a known message count and stop on its own, making accepted/rejected results comparable across runs. Creating a goroutine for every publish would move the unbounded backlog from the channel into goroutine stacks, defeating the pool's memory bound. Include worker count, queue capacity, artificial processing delay, and the four counters in the run record so another person can interpret throughput and overload. ```sh mkdir -p internal/dispatch journal @@ -333,14 +446,20 @@ go vet ./... Create `internal/dispatch` and `journal` before saving their files; the `mkdir -p` command is safe to run earlier than the final check block. `go test -race ./...` instruments the test binaries to report unsynchronized memory access during executed tests. It is slower than normal testing and cannot prove unexecuted paths are race-free. `go vet` checks static patterns; it does not replace the race run. +🧰 **What each command does:** `mkdir -p` creates the source and journal directories if needed. `gofmt` formats the Go implementation and command packages. `go test -race ./...` runs the repository tests with race detection for executed paths. `go vet ./...` checks for suspicious Go patterns. Race detection and vet provide different evidence; neither proves paths that were not exercised. + A finite burst generator publishes a configured count and then stops; record its rate, queue capacity, worker count, and artificial delay. A bounded queue has fixed capacity: when workers are slower than arrival, overload becomes measurable instead of consuming memory indefinitely. Worker cancellation is cooperative: handlers must check contexts and use deadlines on blocking operations. Observe accepted/rejected counts and total shutdown time, not just whether the process eventually exits. +The measurements answer different questions: accepted and rejected show admission behavior, completed and failed show worker outcomes, and elapsed shutdown time shows whether the drain respected its bound. A finite generator makes the offered load known; the artificial delay makes the service rate slow enough to trigger pressure. The queue prevents waiting jobs from growing without limit, but it cannot stop one handler from blocking forever unless that handler checks cancellation or uses deadlines for its own network/database calls. Process exit alone is weak evidence because forced termination can discard queued work without reporting it. + To inspect overload, temporarily make a worker slow and publish a finite burst larger than the queue plus active workers. Confirm the measured `Full` outcomes, then reduce the burst and confirm normal handling resumes. Observe shutdown time under the deadline; do not infer that queued work completed solely because the process exited. +The slow-worker run should push arrivals beyond the pool's total immediate capacity: active worker slots plus waiting queue slots. `Full` outcomes confirm that backpressure is bounded and visible. A smaller burst should fit under available capacity or be drained quickly enough to avoid rejection, showing that the system recovers after pressure rather than remaining stuck. During shutdown, compare elapsed time to the configured deadline and inspect completed/failed counts; jobs still queued or cancelled at expiry were not proven complete. Then compare that application evidence with the MQTT acknowledgement trace from the first section. + **Done when:** A burst causes observable bounded overload, normal ping/pong recovers afterward, shutdown finishes by its deadline, and the race detector passes. Explain the difference between broker acknowledgement, queue admission, and completed application work. ## ✅ Result — the story so far The receive callback now has a defined small job: copy and admit work into bounded queues, then return. Workers validate and handle admitted messages, while counters make overload visible and shutdown follows a deliberate stop-admission, drain, and disconnect order. In this lesson's in-memory design, automatic MQTT acknowledgement can happen before worker completion, so a process crash can still lose queued work; durable admission is a later step. -Previous: [05-contracts-validation.md](05-contracts-validation.md). Next: [07-command-response.md](07-command-response.md). +Previous: [05A — Backend message processing](05a-backend-message-processing.md). Next: [07-command-response.md](07-command-response.md). diff --git a/lessons/07-command-response.md b/teaching/course-source/07-command-response.md similarity index 68% rename from lessons/07-command-response.md rename to teaching/course-source/07-command-response.md index 0e782ae..c806d61 100644 --- a/lessons/07-command-response.md +++ b/teaching/course-source/07-command-response.md @@ -26,7 +26,9 @@ sequenceDiagram **Before each implementation step, ask:** Which event changes command state: broker acknowledgement, device acceptance, device completion, or timeout? Keep those events separate. Follow [Lesson 0](00-system-lifecycle.md) for the full client and broker lifecycle. -Prerequisite: Sections 5–6 validate messages and bound concurrent processing. The backend and simulator continue to read their shared disposable password from `configs/lab.toml`; select each broker username with `-username` when starting the process. +Prerequisite: Section 5 validates messages, Section 5A turns a heartbeat into an application status view, and Section 6 bounds concurrent processing. The backend and simulator continue to read their shared disposable password from `configs/lab.toml`; select each broker username with `-username` when starting the process. + +**Scope:** This section uses an in-memory command store. The record is registered before publication to prevent a fast-response race, but process restart loses pending and completed command records. An HTTP request is not introduced until Section 8 and is not durable acceptance until Sections 13–14 add database state and recoverable publication intent. Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Save the complete implementation blocks at the project-relative paths shown below. This lesson provides the command-state and simulator-deduplication code; the exercises ask you to trace its evidence and failure behavior. @@ -34,18 +36,25 @@ Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Response Topic tells the responder where a result should be published. Correlation Data lets the requester recognize which pending request it belongs to. The broker forwards these properties; it does not run the command, create a result, or enforce your timeout. +This first command is intentionally a harmless `ping`: it teaches how to track request identity, broker acknowledgement, device result, timeout, and duplicate delivery. It does not change a vending machine's catalog or configuration. Sections 10 and 12 build desired state and versioned vending updates on top of this command lifecycle. + This fragment is the packet-construction portion of a service method, not a standalone program. `commandJSON` is an encoded validated command and `correlation` is owned by its pending record: ```go +commandTopic, err := contracts.DeviceTopic("device-001", contracts.KindCommands) +if err != nil { return err } +responseTopic, err := contracts.DeviceTopic("device-001", contracts.KindResponses) +if err != nil { return err } + // MQTT 5 carries the reply destination and request identity in packet properties. request := &paho.Publish{ - Topic: "lab/v1/devices/device-001/commands", + Topic: commandTopic.String(), QoS: 1, Retain: false, Payload: commandJSON, Properties: &paho.PublishProperties{ ContentType: "application/json", - ResponseTopic: "lab/v1/devices/device-001/responses", + ResponseTopic: responseTopic.String(), CorrelationData: append([]byte(nil), correlation...), }, } @@ -65,7 +74,7 @@ Pass `request` to the bounded publication operation after registering pending st 2. Backend stores the expected device, command ID, correlation, and deadline. 3. Backend publishes. The simulator can respond before the publish call returns. 4. Backend validates and applies device evidence independently of the broker acknowledgement. -5. If the waiting deadline expires, backend removes the waiter and records unknown outcome. A later result follows the documented late-result policy. +5. If the waiting deadline expires, the backend records unknown outcome. In this reference store, a later validated result with matching device and correlation can resolve `unknown`; `completed` and `rejected` remain terminal. Registering after step 3 loses the race with a fast response. Holding the command-map mutex across publishing can block the callback needed to record that response. A context timeout stops a local wait; it cannot recall bytes already delivered to a device. @@ -75,6 +84,12 @@ Registering after step 3 loses the race with a fast response. Holding the comman | Correlation Data | Request/response mechanism | Matches a response; does not authenticate it or prevent repeated effects | | Command ID | Application, stable across retries | Keys the dedup record and business status | +### 🧭 Define the application state model + +The intended lifecycle is `created → pending delivery → accepted → completed/rejected`; a response deadline moves unresolved work to `unknown`. A valid late device result may resolve `unknown` in this reference implementation, while `completed` and `rejected` remain terminal. `BrokerAcked` is an independent transport fact and does not advance that lifecycle. + +The example inserts a record directly as `Pending` before calling PUBLISH, so the moment of creation is not a separately queryable status. This keeps the in-memory teaching implementation small while preserving the important transition and failure boundaries. The exercises should state this modeling choice and its limits. + ### ♻️ Make duplicate behavior observable Use a synthetic effect counter. Send command C twice with the same content, then C with changed content. Expect one effect, a replayed recorded result, then a conflict. Also send the same C in two separate publish calls: application duplicates need not carry MQTT DUP. The DUP flag describes protocol retransmission, so deduplicating only packets with DUP set is wrong. @@ -96,6 +111,10 @@ Use the MQTT 5 Response Topic and Correlation Data as transport metadata, while Save the following complete files as `internal/commands/store.go` and `service.go`. The service stores pending state before publishing, keeps broker acknowledgement separate from device status, and matches a response by device, command ID, and Correlation Data. +### 🔍 `store.go` — file overview + +This file stores in-memory command records and enforces correlation and status transitions. Its record keeps broker acknowledgement separately from device-reported status. + `internal/commands/store.go`: ```go @@ -229,8 +248,48 @@ func (s *Store) List() []Record { } ``` +#### 🏗️ `NewStore` — initialize command state + +**Summary:** Creates the map that indexes records by device and command ID. + +**Details:** The store is process-local; a restart loses pending and completed records. Section 13 replaces this volatile lifetime for durable command state. + +#### ➕ `Insert`, `Get`, and `Update` — manage owned records + +**Summary:** Add a unique command, retrieve a copy, or replace an existing record. + +**Details:** The mutex protects the map, and each method copies `CorrelationData` so a caller cannot mutate stored bytes after the call. The composite key prevents two devices using the same command ID from colliding. + +#### 📬 `MarkBrokerAck` — record transport evidence + +**Summary:** Stores that the broker acknowledged the command publication and its reason code. + +**Details:** This method changes `BrokerAcked`, not the device `Status`. A successful broker acknowledgement does not mean the device accepted or completed the command. + +#### ⏳ `MarkUnknownIfPending` — preserve uncertain outcomes + +**Summary:** Marks a pending or accepted command as unknown while preserving terminal results. + +**Details:** A response may arrive before a publish timeout or expiry path runs. The status check prevents a later timeout from overwriting `Completed` or `Rejected`. + +#### 🔗 `ApplyResult` — match and apply device evidence + +**Summary:** Requires a known command, matching device, and matching Correlation Data before changing its application status. + +**Details:** Correlation bytes are compared by content. Terminal results do not regress when a duplicate accepted response arrives. Correlation is a matching mechanism; topic ACLs and device identity validation remain separate authorization checks. + +#### 📋 `List` — copy a snapshot for iteration + +**Summary:** Returns all command records without exposing stored correlation byte slices. + +**Details:** The snapshot lets expiry logic iterate without holding the store lock while it performs subsequent updates. + `internal/commands/service.go`: +### 🔍 `service.go` — file overview + +This file coordinates command state and the transport adapter. It creates and stores pending state before publishing, handles device results, and records deadline expiry as uncertainty. + ```go package commands @@ -244,7 +303,7 @@ import ( ) type Publisher interface { - PublishCommand(context.Context, string, contracts.Command, string, []byte) (byte, error) + PublishCommand(context.Context, string, contracts.Command, contracts.TopicName, []byte) (byte, error) } // Service coordinates command identity, storage, publication, and deadlines. @@ -260,6 +319,8 @@ func NewService(store *Store, publisher Publisher, timeout time.Duration) *Servi } func (s *Service) CreateAndPublish(ctx context.Context, deviceID, commandID string) (Record, error) { + responseTopic, err := contracts.DeviceTopic(deviceID, contracts.KindResponses) + if err != nil { return Record{}, fmt.Errorf("build response topic: %w", err) } // Store pending state first so a fast device response can match immediately. now := s.now() correlation := make([]byte, 16) @@ -267,7 +328,6 @@ func (s *Service) CreateAndPublish(ctx context.Context, deviceID, commandID stri record := Record{CommandID: commandID, DeviceID: deviceID, CorrelationData: correlation, CreatedAt: now, Deadline: now.Add(s.timeout), Status: Pending} if err := s.store.Insert(record); err != nil { return Record{}, err } command := contracts.Command{SchemaVersion: contracts.SchemaVersion, MessageID: "message-"+commandID, DeviceID: deviceID, OccurredAt: now.Format(time.RFC3339), CommandID: commandID, Command: "ping"} - responseTopic := "lab/v1/devices/"+deviceID+"/responses" ctx, cancel := context.WithDeadline(ctx, record.Deadline) defer cancel() reason, err := s.publisher.PublishCommand(ctx, deviceID, command, responseTopic, append([]byte(nil), correlation...)) @@ -301,9 +361,43 @@ func (s *Service) Get(commandID string) (Record, bool) { ``` -Read `references/paho.golang-v0.23.0/paho/cp_publish.go` for `paho.PublishProperties`. Set `ResponseTopic` to `lab/v1/devices/device-001/responses` and `CorrelationData` to opaque bytes associated with the pending command. Establish the backend response subscription before sending. Register pending state before publishing so an immediate response cannot beat registration. +#### 🧰 `NewService` — connect command dependencies -Save this complete adapter as `internal/mqtt/command_publisher.go`. It checks the response destination before publishing, copies Correlation Data, uses QoS 1, and returns the broker reason code separately from the device result. +**Summary:** Binds a command store, publisher, clock, and response timeout into one service. + +**Details:** The service coordinates business state; the `Publisher` interface hides the MQTT implementation so state transitions can be reasoned about independently of the client. + +#### 🚀 `CreateAndPublish` — register before sending + +**Summary:** Creates correlation bytes and a pending record, stores it, then publishes with a deadline. + +**Details:** Inserting first handles a fast response that arrives before `PublishCommand` returns. If publication errors or times out, the broker may still have received it, so the method marks the outcome unknown rather than asserting the device did nothing. A successful return records the broker acknowledgement separately. + +#### 📩 `HandleResult` — apply a matched result + +**Summary:** Delegates response identity and correlation checks to the store. + +**Details:** Call it only after the receiver has parsed the topic and validated the result contract. The store then verifies that this application result belongs to the expected pending command. + +#### ⏰ `Expire` — mark response timeouts as unknown + +**Summary:** Scans commands whose deadlines passed and changes pending/accepted records to unknown. + +**Details:** Expiry describes what the backend knows, not what the device did. A late device result can still arrive and needs the course's documented late-result policy. + +#### 🔎 `Get` — query a command record + +**Summary:** Finds a command by its application command ID. + +**Details:** The method returns the application record for HTTP/status use; it does not query the device or the broker. + +Read `references/paho.golang-v0.23.0/paho/cp_publish.go` for `paho.PublishProperties`. Build the response destination with `contracts.DeviceTopic(deviceID, contracts.KindResponses)`, then pass its string form to Paho's `ResponseTopic` field. Attach `CorrelationData` as opaque bytes associated with the pending command. Establish the backend response subscription before sending. Register pending state before publishing so an immediate response cannot beat registration. + +### 🔍 `command_publisher.go` — file overview + +This file adapts the command service to AutoPaho. It checks the requested reply destination, encodes the command, and publishes one MQTT 5 request with Response Topic and Correlation Data. + +Save this complete adapter as `internal/mqtt/command_publisher.go`. ```go package mqtt @@ -323,23 +417,26 @@ type CommandPublisher struct { Manager *autopaho.ConnectionManager } -func (p CommandPublisher) PublishCommand(ctx context.Context, deviceID string, command contracts.Command, responseTopic string, correlation []byte) (byte, error) { +func (p CommandPublisher) PublishCommand(ctx context.Context, deviceID string, command contracts.Command, responseTopic contracts.TopicName, correlation []byte) (byte, error) { // Authorize the reply destination before putting it in the MQTT packet. - authorized := "lab/v1/devices/" + deviceID + "/responses" + authorized, err := contracts.DeviceTopic(deviceID, contracts.KindResponses) + if err != nil { return 0, fmt.Errorf("build authorized response topic: %w", err) } if responseTopic != authorized { return 0, fmt.Errorf("unauthorized response topic") } if command.DeviceID != deviceID { return 0, fmt.Errorf("command device does not match publish destination") } + commandTopic, err := contracts.DeviceTopic(deviceID, contracts.KindCommands) + if err != nil { return 0, fmt.Errorf("build command topic: %w", err) } payload, err := json.Marshal(command) if err != nil { return 0, fmt.Errorf("encode command: %w", err) } operation, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() // Correlation Data is copied so the packet owns stable bytes for this publish. ack, err := p.Manager.Publish(operation, &paho.Publish{ - Topic: "lab/v1/devices/" + deviceID + "/commands", + Topic: commandTopic.String(), QoS: 1, Payload: payload, Properties: &paho.PublishProperties{ ContentType: "application/json", - ResponseTopic: responseTopic, + ResponseTopic: responseTopic.String(), CorrelationData: append([]byte(nil), correlation...), }, }) @@ -350,6 +447,12 @@ func (p CommandPublisher) PublishCommand(ctx context.Context, deviceID string, c } ``` +#### 📤 `PublishCommand` — send the transport request + +**Summary:** Verifies device and reply-topic agreement, encodes the command, and waits for a bounded QoS 1 publish result. + +**Details:** It copies correlation bytes into the packet so the request owns stable data. A returned reason code is broker-hop evidence only. It is returned separately from the device's eventual application result, which arrives later on the response subscription. + The receive callback must copy packet properties while the packet is available. Add these fields to the existing `mqtt.Message` and set them in its receive callback: ```go @@ -370,7 +473,11 @@ if properties != nil { } ``` -Save this complete result receiver as `internal/commands/receive.go`. It validates the MQTT topic and payload before applying a response, then matches the received Correlation Data against the pending command. +### 🔍 `receive.go` — file overview + +This file is the boundary between an incoming response packet and command state. It checks topic kind and payload contract before passing device identity and Correlation Data to the service. + +Save this complete result receiver as `internal/commands/receive.go`. ```go package commands @@ -395,6 +502,12 @@ func HandleMessage(service *Service, message mqtt.Message) error { } ``` +#### 📩 `HandleMessage` — validate and match a result + +**Summary:** Parses a response topic, decodes a `CommandResult`, then asks the service to match and apply it. + +**Details:** Malformed or wrong-kind messages stop before the command store is touched. The device ID comes from the parsed topic and is checked against the body and pending record. Correlation Data is copied before it enters state handling. + ## 🧩 2. Model evidence explicitly > **❓ Question:** **What should the backend show after a PUBACK but before any device response arrives?** @@ -411,7 +524,11 @@ Protect concurrent access and define allowed transitions. A completed or rejecte Record results by stable command ID and replay the prior result for duplicates instead of applying the simulated effect twice. -Save this complete in-memory simulator cache as `internal/simulator/dedup.go`. It serializes admission and the synthetic effect under one lock, replays an identical result, rejects reuse of an ID with different content, and refuses new work when full rather than evicting live deduplication state. +### 🔍 `dedup.go` — file overview + +This file records a bounded set of command IDs and their outcomes in simulator memory. It ensures the demonstration does not repeat the same synthetic effect while this process remains alive. + +Save this complete in-memory simulator cache as `internal/simulator/dedup.go`. ```go package simulator @@ -443,7 +560,8 @@ func NewDeduplicator(capacity int) *Deduplicator { } func AuthorizedResponseTopic(deviceID, requested string) bool { - return requested == "lab/v1/devices/"+deviceID+"/responses" + expected, err := contracts.DeviceTopic(deviceID, contracts.KindResponses) + return err == nil && requested == expected.String() } // Apply executes effect at most once for a device/command ID while this process lives. @@ -467,6 +585,24 @@ func (d *Deduplicator) Apply(command contracts.Command, effect func() (contracts } ``` +#### 🏗️ `NewDeduplicator` — create bounded replay state + +**Summary:** Allocates the cache and raises a too-small capacity to one. + +**Details:** Capacity prevents this example from growing its map forever. The cache is volatile; restarting the simulator forgets the IDs, so it does not provide durable idempotency. + +#### 🔐 `AuthorizedResponseTopic` — constrain reply routing + +**Summary:** Allows only the response topic assigned to this device ID. + +**Details:** Correlation bytes match a response to a request, but do not authorize where the response is sent. The simulator checks the destination before publishing. + +#### ♻️ `Apply` — execute or replay one command + +**Summary:** Fingerprints the command, checks the device/command key under a lock, and runs the effect only when no prior result exists. + +**Details:** An identical retry receives the stored result; reusing an ID for different content fails; a full cache refuses new work instead of evicting a result that still protects against a duplicate. Holding the lock across the synthetic effect makes admission atomic in this small example. A real network call or long-running operation should not run under this lock. + Use the deduplicator in the complete simulator handler below. Save it as `internal/simulator/respond.go`; it checks the requested destination before applying the effect, then publishes the result with the original Correlation Data. ```go @@ -511,6 +647,12 @@ func Respond(ctx context.Context, manager *autopaho.ConnectionManager, cache *De } ``` +#### 📩 `Respond` — validate, deduplicate, and reply + +**Summary:** Rejects retained or misrouted commands, validates the contract and reply destination, obtains a cached result, then publishes it with the request's correlation bytes. + +**Details:** Route and destination checks happen before the simulated effect so an unauthorized request cannot trigger it. `cache.Apply` ensures a repeated command reuses its first result while this process remains alive. The bounded publish context prevents a stuck response operation from occupying a worker indefinitely. PUBACK for the result still proves only the broker hop; the backend must receive and match the response separately. + Replace the Section 5 simulator entrypoint with this complete version to use the deduplication and response handler. It retains the validated heartbeat publisher and routes every received command through `Respond`. ```go @@ -541,9 +683,12 @@ func run() error { if err != nil { return fmt.Errorf("load config: %w", err) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - base := "lab/v1/devices/" + cfg.MQTT.DeviceID + 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(ctx, cfg, []string{base+"/commands"}, inbox) + manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{commandFilter}, inbox) if err != nil { return fmt.Errorf("start MQTT: %w", err) } defer mqtt.Stop(manager) // Keep recent command results so repeated delivery does not repeat the effect. @@ -560,7 +705,7 @@ func run() error { heartbeat := contracts.Heartbeat{SchemaVersion: contracts.SchemaVersion, MessageID: fmt.Sprintf("heartbeat-%d-%d", time.Now().Unix(), sequence), DeviceID: cfg.MQTT.DeviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339)} payload, err := json.Marshal(heartbeat) if err != nil { return fmt.Errorf("encode heartbeat: %w", err) } - if err := mqtt.Publish(ctx, manager, base+"/telemetry", payload); err != nil { log.Printf("heartbeat publish: %v", err) } + if err := mqtt.Publish(ctx, manager, telemetryTopic, payload); err != nil { log.Printf("heartbeat publish: %v", err) } case message := <-inbox: if err := simulator.Respond(ctx, manager, cache, cfg.MQTT.DeviceID, message); err != nil { log.Printf("command rejected or response failed topic=%q reason=%v", message.Topic, err) } } @@ -568,6 +713,12 @@ func run() error { } ``` +#### 🧭 `main` and `run` — operate the simulator lifecycle + +**Summary:** `main` reports a fatal error; `run` starts the device client, creates the bounded deduplication cache, publishes heartbeats, and delegates commands to `Respond`. + +**Details:** One cache is created per process so duplicate deliveries share replay state. The ticker is stopped and MQTT manager closed on return. The receive loop invokes `Respond` outside the callback, keeping packet reception separate from validation, effect handling, and reply publication. Restarting the simulator clears this cache, which is why it demonstrates process-local idempotency rather than durable device guarantees. + The device checks that the requested response destination exactly matches its own response topic before publishing. Correlation Data matches the result to the pending request; it does not authorize a destination. Retained one-shot commands are rejected before parsing. ## 🧪 4. Exercise timing and failures diff --git a/lessons/08-http-commands.md b/teaching/course-source/08-http-commands.md similarity index 95% rename from lessons/08-http-commands.md rename to teaching/course-source/08-http-commands.md index 4d190b8..103ae52 100644 --- a/lessons/08-http-commands.md +++ b/teaching/course-source/08-http-commands.md @@ -33,6 +33,8 @@ sequenceDiagram Prerequisite: Section 7 provides a command service and queryable in-memory states. The backend and simulator continue to read their shared disposable password from `configs/lab.toml`; select each broker username with `-username` when starting the process. +**Scope and trust boundary:** This lesson's HTTP endpoint is unauthenticated and binds to loopback for the disposable lab. Do not expose it to another network; Section 11 adds API authentication and per-device authorization. HTTP 202 means the command was admitted to process-local memory, not durably stored. A restart loses the command and retry-key maps until Sections 13–14 add persistence and recoverable publication intent. + Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Save the complete HTTP implementation blocks at the project-relative paths shown below. This lesson includes the handlers and server setup; the remaining exercises trace request, retry, and device-result behavior. ## 🌐 Translate evidence between HTTP and MQTT @@ -54,7 +56,7 @@ Predict a lost HTTP response: the server admits C, but the caller never sees 202
If the backend restarts after returning 202, can this lesson recover C? -No. The lesson's in-memory command and retry-key maps disappear. HTTP 202 describes admission under the current implementation, not disk durability. Sections 13–15 add database admission/outbox and process recovery; until then, document this restart limit in the lab API. +No. The lesson's in-memory command and retry-key maps disappear. HTTP 202 describes admission under the current implementation, not disk durability. Sections 13–14 add database admission and the outbox; Section 15 separates the processes. Until then, document this restart limit in the lab API.
@@ -271,7 +273,13 @@ func run() error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() inbox := make(chan mqtt.Message, 16) - topics := []string{"lab/v1/devices/+/telemetry", "lab/v1/devices/+/responses", "lab/v1/devices/+/sales"} + telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry) + if err != nil { return err } + responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses) + if err != nil { return err } + salesFilter, err := contracts.DeviceFilter(contracts.KindSales) + if err != nil { return err } + topics := []contracts.TopicFilter{telemetryFilter, responsesFilter, salesFilter} manager, err := mqtt.Start(ctx, cfg, topics, inbox) if err != nil { return fmt.Errorf("start MQTT: %w", err) } defer mqtt.Stop(manager) diff --git a/lessons/09-sessions-recovery.md b/teaching/course-source/09-sessions-recovery.md similarity index 85% rename from lessons/09-sessions-recovery.md rename to teaching/course-source/09-sessions-recovery.md index 2102a2a..fd9f7e0 100644 --- a/lessons/09-sessions-recovery.md +++ b/teaching/course-source/09-sessions-recovery.md @@ -2,7 +2,7 @@ Prerequisite: Section 8 exposes commands and their evidence; use only synthetic ping effects. -Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Create files at the project-relative paths shown below, not under `lessons/`. Continue the same application and pinned Paho v0.23.0 dependency. These are guided implementation exercises; attempt each step before requesting a full solution. +Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Create files at the project-relative paths shown below, not under `teaching/course-source/`. Continue the same application and pinned Paho v0.23.0 dependency. These are guided implementation exercises; attempt each step before requesting a full solution. ## A connection is temporary; a session may outlive it @@ -30,7 +30,20 @@ In AutoPaho v0.23.0, `CleanStartOnInitialConnection` affects the manager's first At t=0 the simulator has subscribed at QoS 1 with expiry 60. At t=5 it disconnects. At t=10 the backend publishes a nonretained QoS 1 ping. At t=20 the same simulator ID reconnects with Clean Start false. Assuming the broker retained the session, accepted the publication, and no shorter message expiry or resource limit intervened, the subscription allows queued delivery. Reconnect after the session expires and the old queue is gone. -Creating a new subscriber after publication is different: it had no prior subscription/session to receive that queued nonretained message. Retained state, studied next, is associated with a topic and can initialize a new subscriber. +Creating a new subscriber after publication is different: it had no prior subscription/session to receive that queued nonretained message. Retained state is associated with a topic and can initialize a new subscriber, even when it has no prior session. + +### Compare queued events with retained latest state + +The broker session queue and retained-message store answer different questions: + +| Mechanism | What it represents | Who can receive it later | +| --- | --- | --- | +| Nonretained QoS 1 publication to an offline session | A one-shot event the broker may queue for an existing matching subscription | That session, while its subscription and expiry remain valid | +| Retained publication | The latest retained value for a topic, replacing the prior retained value | A subscriber that newly subscribes to the topic, including one with a new Client ID | + +Run this comparison only against the disposable broker and simulator. First finish the queued-ping experiment and reconnect the original simulator. Keep it online so the retained probe cannot also accumulate in its offline session. Publish a synthetic retained `ping` to its lab command topic with the MQTT 5 CLI and authorized lab backend identity; confirm the Section 7 simulator rejects it before executing it. Then subscribe with an observer using a fresh Client ID and the lab simulator identity; it should receive the stored latest value immediately despite having no prior session. Use no physical device or production credential. + +Immediately clear the retained value by publishing an empty payload with RETAIN set to the same topic (Mosquitto CLI uses `-r -n`). Verify a fresh subscription receives no replay. Do not leave retained one-shot commands in the lab. The point is to see that retained delivery initializes the subscriber with the latest value; it is not an event log and must not be used to queue executable commands. ### Put each recovery responsibility in the correct process diff --git a/lessons/10-expiry-presence-state.md b/teaching/course-source/10-expiry-presence-state.md similarity index 72% rename from lessons/10-expiry-presence-state.md rename to teaching/course-source/10-expiry-presence-state.md index 0818967..6d8d49c 100644 --- a/lessons/10-expiry-presence-state.md +++ b/teaching/course-source/10-expiry-presence-state.md @@ -43,7 +43,7 @@ A retained publication replaces the stored value for one exact topic. It can ini Use a desired-configuration topic for state such as revision 7 and a temperature setting. Use nonretained command topics for one-shot actions. A retained "dispense" would risk replay when someone subscribes later. -Extend the ACL deliberately before introducing topics: backend writes `lab/v1/devices/+/desired`, simulator reads its exact `.../device-001/desired`; simulator writes `.../device-001/reported`, backend reads `.../+/reported`. Reload/restart the lab and verify both allowed and denied directions. Extend Lesson 5's topic parser and contracts as well. +Extend the ACL and the shared topic contract before using these flows: backend writes `lab/v1/devices/+/desired`, simulator reads its exact `.../device-001/desired`; simulator writes `.../device-001/reported`, backend reads `.../+/reported`. The complete `topic.go` update below adds those flow kinds to `DeviceTopic`, `DeviceFilter`, `DeviceSubscription`, and `ParseTopic`. Reload/restart the lab and verify both allowed and denied directions; the broker ACL remains its own source of authorization. Implement desired and reported revision records. Store the highest applied revision in the simulator and explain the initial in-memory restart limit. Receiving revision 6 after 7 must not overwrite 7. A backend PUBACK for revision 7 proves neither application nor reported revision 7; wait for the device's report. @@ -63,13 +63,101 @@ mosquitto_pub -h 127.0.0.1 -p 18883 -V mqttv5 -u simulator -P ' 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 +} +``` + +All three builders now accept these new kinds through the shared allowlist. Do not add arbitrary user-supplied path segments or wildcards to `DeviceTopic`. + +Build the will topic before creating the simulator connection, then add these fields to its AutoPaho configuration. The surrounding `run` function already imports `contracts`: + +```go +presenceTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindPresence) +if err != nil { return fmt.Errorf("build presence topic: %w", err) } + WillMessage: &paho.WillMessage{ - Topic: "lab/v1/devices/device-001/presence", + Topic: presenceTopic.String(), QoS: 1, Retain: true, Payload: []byte(`{"status":"offline"}`), diff --git a/lessons/11-security-boundaries.md b/teaching/course-source/11-security-boundaries.md similarity index 98% rename from lessons/11-security-boundaries.md rename to teaching/course-source/11-security-boundaries.md index c04d328..0b7a110 100644 --- a/lessons/11-security-boundaries.md +++ b/teaching/course-source/11-security-boundaries.md @@ -76,4 +76,4 @@ TLS protects traffic, but a shared credential does not distinguish individual de
-**Done when:** explain every boundary, run the failure matrix against the lab, and show that a denied user or device cannot produce a simulated effect. Continue with Section 12 in [COURSE.md](../COURSE.md); the [advanced MQTT lesson](16-mqtt5-controls.md) belongs after the persistence/process exercises. +**Done when:** explain every boundary, run the failure matrix against the lab, and show that a denied user or device cannot produce a simulated effect. Continue with Section 12 in [COURSE.md](../../COURSE.md); the [advanced MQTT lesson](16-mqtt5-controls.md) belongs after the persistence/process exercises. diff --git a/lessons/11b-dynamic-security.md b/teaching/course-source/11b-dynamic-security.md similarity index 100% rename from lessons/11b-dynamic-security.md rename to teaching/course-source/11b-dynamic-security.md diff --git a/teaching/course-source/12-vending-workflows.md b/teaching/course-source/12-vending-workflows.md new file mode 100644 index 0000000..609acb7 --- /dev/null +++ b/teaching/course-source/12-vending-workflows.md @@ -0,0 +1,110 @@ +# 🥤 Lesson 12 — Implement representative vending workflows + +## 🎬 Start with the system story + +**❓ Question:** **A vending machine reports a sale, receives a new catalog, and asks for configuration. Which side owns each fact, and which MQTT message proves that a change reached the device?** + +**💬 Answer:** The device owns what it observed or applied locally; the backend owns the accepted business record and the version it intends the device to use. A broker PUBACK proves only a QoS delivery hop. The device must publish an application result or reported revision to show what it accepted or applied. + +```mermaid +flowchart LR + D[Device simulator] -->|sales report / health / logs| B[Mosquitto] + B -->|delivered publication| I[Backend ingestion] + I -->|validate, deduplicate, record| S[In-memory state] + A[Backend workflow] -->|catalog or config request| B + B -->|command / desired state| D + D -->|accepted / completed / reported revision| B +``` + +This lesson extends the contracts and command/response flow with synthetic vending data. Keep all data disposable and simulated. Do not connect the exercises to production devices, payment processing, or a production database. + +## 💰 1. Ingest and deduplicate sales + +> **❓ Question:** **The simulator republishes report `sale-0042` after losing its response. How do you avoid counting the same sale twice?** + +The backend parses the topic and validates the report as Lesson 5 established. Then it uses a stable application identity such as `(device_id, report_id)` to detect repeats. An MQTT packet identifier is scoped to a protocol exchange and is not a durable sale ID. + +For this lesson, keep the deduplication map in memory and make its limitation explicit: a process restart forgets the map. For each new report, record the report ID, its accepted/rejected result, and a synthetic effect count. For a duplicate, return or log the prior outcome without repeating the effect. Keep malformed or mismatched reports out of business state. + +The simulator may publish an application-level receipt keyed by report ID. That receipt can mean “the backend saved this report in its current state”; it must not imply durable storage until Lesson 13 adds PostgreSQL. MQTT PUBACK and the report receipt are separate signals. + +### 🧪 Predict, then try it + +1. Publish the same valid report twice with the same report ID. Predict the effect counter and the two MQTT deliveries. +2. Repeat with a changed amount but the same ID. Define whether the backend rejects ID reuse with different content or returns the original result; do not silently apply a second meaning. +3. Restart the backend and replay the report. Observe that the in-memory implementation cannot deduplicate across restart. + +**Expected evidence:** Two broker deliveries, one business effect before restart, and a documented loss of deduplication knowledge after restart. + +## 📦 2. Deliver a versioned product catalog + +> **❓ Question:** **If a device receives catalog revision 8 after revision 9, should it replace its current catalog?** + +Treat a catalog as versioned application data. Give it a schema version, a stable message ID, a catalog/job ID, and a monotonically increasing revision. The backend publishes the intended catalog; the simulator validates the topic and payload, compares revisions, and reports the revision it applied. Older revisions must not overwrite newer local state. + +Use a nonretained command when each delivery represents a one-time operation. If the catalog is modeled as current desired state, Lesson 10's retained-state rules may be more appropriate: publish a retained snapshot keyed by device, and let the device report the revision it has applied. Decide which model you are testing; do not mix “deliver this job once” with “this is the latest state.” + +```text +backend publishes catalog revision 9 +broker PUBACK → confirms backend-to-broker QoS hop only +device receives catalog → validates and compares revision +device publishes applied revision 9 → application evidence +``` + +An application response should include the job or catalog ID and revision, so the backend can match it to the intended update. Do not claim that a successful publish or PUBACK means the catalog was installed. + +### 🧪 Predict, then try it + +1. Publish revision 9, then revision 8. Predict the simulator's reported revision. +2. Send revision 9 twice with the same job ID. Confirm the second delivery does not reapply the update. +3. Restart the simulator and explain which revision state is lost in this in-memory stage. + +## ⚙️ 3. Model configuration fetch and update + +> **❓ Question:** **What should happen when a device asks for configuration while the backend is preparing a newer revision?** + +Give each configuration snapshot a device identity, revision, and schema version. For device-initiated fetch, the device publishes a request with a request ID; the backend replies on an authorized response destination or the defined device configuration topic. For a backend-initiated update, the backend publishes the desired revision and the device reports the applied revision. + +Define a conflict rule before coding. A simple lab rule is “accept only revisions newer than the simulator's current revision.” If requests can race with updates, include the request ID or expected revision in the response and discard stale responses. Correlation Data can associate an MQTT response with a request, but it is not authorization and does not replace the application revision. + +```text +device → config/request (request_id, known_revision) +backend → validate request and select current revision +backend → config/response (request_id, revision, settings) +device → validate request_id and revision; apply only if current +device → reported revision (application confirmation) +``` + +Use synthetic settings only. Never put credentials or secrets in a retained configuration message; retained data may be replayed to later subscribers. + +## ❤️ 4. Add health and bounded logs + +> **❓ Question:** **Does a recent heartbeat prove that a machine is healthy and ready to vend?** + +No. Keep three observations distinct: MQTT connection status, recent application heartbeat, and device-reported health. A connected client can be malfunctioning; a fresh heartbeat can report an error; a Will indicates an unexpected connection loss but does not diagnose the device. + +For log events, bound payload size and event frequency, sanitize control characters and secrets, and avoid logging full untrusted payloads. Decide which events are disposable telemetry and which require durable retention. Section 13 introduces durable persistence for important reports. + +## 🔎 5. Compare with the existing backend + +Use the existing Robot Market backend as a read-only reference. Trace the source path for sales ingestion, catalog/config publication, and device health. Record the actual topic, contract identity, acknowledgement, retry, and deduplication behavior you observe. Do not infer that a field or identifier has the same meaning as the lab just because the names look similar. + +### 🧪 Review questions + +1. Why is an MQTT packet ID unsuitable as a sales report ID? +2. What does a broker PUBACK prove in the catalog flow, and what proves device application? +3. How does the simulator prevent an older revision from replacing a newer one? +4. Which state disappears when the backend or simulator restarts at this stage? +5. Why are connection status, heartbeat freshness, and device health different signals? + +> [!answer]- 📋 Short answers +> +> 1. Packet IDs are protocol-scoped and reusable; business identity must remain stable across retries and restarts. +> 2. PUBACK proves the publisher-to-broker QoS exchange; a validated device result or reported revision is application evidence. +> 3. It stores the highest applied revision and rejects older revisions. +> 4. In-memory deduplication, business effects, command state, and applied simulator revision. +> 5. Each measures a different boundary: transport connection, recent application message, and the device's own reported condition. + +**Done when:** A duplicate sales report causes one in-memory effect, stale catalog/config revisions are rejected, and your notes distinguish transport acknowledgement from device application evidence. + +**Next:** [Lesson 13 — Persist reports and command state](13-persist-reports-command-state.md) replaces restart-losing business state with PostgreSQL transactions and durable MQTT admission. diff --git a/teaching/course-source/13-persist-reports-command-state.md b/teaching/course-source/13-persist-reports-command-state.md new file mode 100644 index 0000000..69460db --- /dev/null +++ b/teaching/course-source/13-persist-reports-command-state.md @@ -0,0 +1,131 @@ +# 🗄️ Lesson 13 — Persist reports and command state in PostgreSQL + +## 🎬 Start with the system story + +**❓ Question:** **The backend receives a QoS 1 sales report, commits it to PostgreSQL, then crashes before acknowledging it. What should happen when the broker delivers it again?** + +**💬 Answer:** The database must recognize the stable report identity and avoid applying the sale twice. The backend can then acknowledge the repeated delivery and return the previously recorded application outcome. MQTT manages protocol delivery; PostgreSQL owns durable business state. + +```mermaid +sequenceDiagram + participant D as Device + participant B as Broker + participant I as Backend ingestion + participant DB as PostgreSQL + D->>B: QoS 1 PUBLISH report_id + B->>I: QoS 1 PUBLISH + I->>I: Parse topic and validate payload + I->>DB: Transaction: dedup key + business effect + DB-->>I: COMMIT + I->>B: PUBACK + Note over I,B: Crash before PUBACK can cause a duplicate delivery +``` + +The acceptance boundary for important work is a successful database commit. The callback may hand work to a bounded worker, but keep the original Paho packet and client available until acknowledgement. A payload copy alone cannot be acknowledged later. + +## 📥 1. Decide when to acknowledge + +> **❓ Question:** **What does Paho do if a callback returns an error? Does that tell the broker to retry?** + +For the pinned Paho v0.23.0 client, automatic acknowledgement sends PUBACK for a QoS 1 message after the callback returns—even if it returned an error. The error is local to the callback chain; it is not an MQTT negative acknowledgement or a retry request. If the callback enqueues the message in RAM and returns, the broker may consider the backend's delivery complete before a worker processes it. + +With `EnableManualAcknowledgment`, Paho does not automatically acknowledge a QoS 1 message when the callback returns. Your application calls `received.Client.Ack(received.Packet)` after reaching its policy boundary. For reports and command results, use this order: + +1. Parse the MQTT topic and validate the message contract (Lesson 5). +2. Submit the message to a bounded worker queue while retaining its packet/client acknowledgement handle. +3. In one PostgreSQL transaction, record the stable identity, apply the business effect or command-state transition, and store the application outcome. +4. Commit the transaction. +5. Call `Ack` and check its error. + +If the process crashes after commit but before PUBACK, the broker may deliver the message again. The unique constraint makes the repeated transaction a no-op for business effects; the backend acknowledges after it recognizes the duplicate. If it crashes before commit, there is no durable admission, so the message must remain recoverable from the MQTT session/broker according to QoS, expiry, and persistence settings. + +### ❓ What does a callback error mean in each mode? + +| Mode | Callback returns `nil` | Callback returns an error | What causes a retry? | +| --- | --- | --- | --- | +| Automatic | Paho sends PUBACK after callback return | Paho still sends PUBACK; error does not request redelivery | Not the callback error. The delivery is complete at the protocol layer. | +| Manual | Paho does not automatically send PUBACK | Paho still does not automatically send PUBACK | Your code must choose recovery; return error alone is not a retry signal. | + +In manual mode, leaving a QoS 1 delivery unacknowledged can occupy an in-flight slot and slow later QoS 1/2 deliveries. Paho's acknowledgement tracker sends acknowledgements in receipt order, so acknowledging a later message can wait behind an earlier one. Disconnect/reconnect redelivery is conditional on session continuity, broker persistence, QoS, and message expiry. Manual acknowledgement is not permanent broker storage. + +For permanently invalid messages, persist a bounded rejection record or a quarantine entry, then acknowledge after that disposition commits. Do not leave poison messages unacknowledged forever. For transient database failures, do not record permanent rejection; stop or back off consumption using a deliberate connection/recovery policy so work remains recoverable. A callback error alone does neither. + +📚 **Pinned implementation:** `references/paho.golang-v0.23.0/paho/client.go` documents callback invocation, manual acknowledgement, `Ack`, and ordered acknowledgement tracking. MQTT 5 Receive Maximum bounds the QoS 1/2 publications that can remain unacknowledged on a connection; it does not bound your Go queue. + +## 🔁 2. Make database effects idempotent + +> **❓ Question:** **Two ingestion workers receive the same report concurrently. What prevents both transactions from counting its items?** + +Use a stable business key and a database uniqueness constraint. Examples include `(device_id, report_id)` for sales reports and `(device_id, command_id)` for device command outcomes. Do not use the MQTT packet identifier; it belongs to an exchange and can be reused. + +Inside one transaction: + +1. Attempt to insert the deduplication/business record under its unique key. +2. If it is new, apply the corresponding inventory/revenue or command-state effect. +3. Store the application response/outcome needed for a repeat. +4. Commit all related changes together. + +If the key already exists, load the previously stored outcome and do not apply the effect again. If the same key arrives with materially different content, record a conflict instead of silently assigning the ID a new meaning. The database constraint arbitrates concurrent workers and remains after process restart; an in-memory map cannot do either reliably. + +### 🧪 Predict, then try it + +- Send one report twice sequentially and once concurrently. Expect one business effect and an identifiable duplicate outcome. +- Force a transaction rollback after recording the dedup key but before the business effect. Verify that neither half is committed. +- Commit the transaction, terminate the process before `Ack`, reconnect under the same eligible session, and observe duplicate delivery without a duplicate effect. + +## 🧹 3. Persist invalid-message disposition + +> **❓ Question:** **Should permanently invalid JSON occupy a Receive Maximum slot indefinitely?** + +No. After safe validation fails, write a quarantine/rejection record with bounded metadata: topic, reason category, receipt time, and a safe message identifier if one could be parsed. Avoid storing secrets or dumping arbitrary payloads. Commit that record, then acknowledge the delivery. For transient infrastructure failures, keep the work recoverable instead of classifying it as permanently invalid. + +This creates a clear distinction: + +| Result | Database action | MQTT action | +| --- | --- | --- | +| Valid, new message | Store identity and business effect atomically | Ack after commit | +| Valid duplicate | Load prior outcome; no repeated effect | Ack after duplicate is recognized | +| Permanently invalid | Commit bounded rejection/quarantine record | Ack after disposition commits | +| Transient DB/storage failure | No false success or permanent rejection | Do not Ack; invoke recovery/backoff policy | + +## 🧱 4. Add PostgreSQL to the lab + +> **❓ Question:** **Which state needs to survive a backend restart, and which state can remain temporary?** + +Persist accepted sales reports, deduplication keys, command state/results, and any outbox intent needed by the next lesson. Keep connection objects, worker queues, and in-flight Paho packet handles in memory; they cannot be made database records. On restart, recover work from durable application rows and eligible MQTT session deliveries rather than pretending the old callback is still alive. + +Use the course's dedicated lab PostgreSQL service only. Add migrations, bounded connection-pool settings, query contexts/deadlines, and repository interfaces used by business code. Use PostgreSQL unique indexes as the final deduplication authority. Define transaction boundaries around a single business decision rather than spreading a report effect over unrelated commits. + +Learner commands, run from the project root after adding the DB files: + +```sh +docker compose -p mqtt5-learning config --quiet +docker compose -p mqtt5-learning up -d postgres +go test ./internal/ingest ./internal/sales ./internal/commands +go test -race ./... +``` + +The first command validates the lab Compose configuration. The second starts only the project-named PostgreSQL service; inspect its health/logs before running integration tests. Tests must use the dedicated lab database and synthetic data. Stop that service with the matching Compose project when finished; do not point these commands at production. + +## 🔄 5. Separate MQTT acknowledgement from application responses + +> **❓ Question:** **After the backend commits a sales report and sends PUBACK, what does the device know?** + +PUBACK completes the device-to-broker QoS 1 delivery hop; it does not say that the backend committed the report. If the device needs application-level evidence, publish a separate receipt after the transaction commits. That receipt should include the stable report ID and a status the device can interpret. The report may be committed even if the receipt is lost, so a retry must receive the stored prior outcome. + +For commands, keep broker publish acknowledgement, device acceptance, and device completion as distinct command states. A database commit that records a command result is backend evidence; it does not retroactively change the meaning of the earlier command PUBACK. + +If database commit and MQTT response publication must survive a crash between them, write an outbox row in the same database transaction as the business state. Lesson 14 handles that publication gap. + +## 🧪 Failure experiments and done criteria + +1. Send a duplicate report and verify the database effect occurs once. +2. Interrupt PostgreSQL before the transaction commits; verify the message is not acknowledged as accepted. +3. Crash after commit but before manual Ack; verify replay returns the previous outcome without repeating the effect. +4. Send a permanently invalid message; verify a safe rejection/quarantine record commits before Ack. +5. Delay acknowledgements until Receive Maximum is reached; observe delivery pressure, then free a slot and confirm delivery resumes. +6. Crash after commit but before publishing an application response. Record the failure window that Lesson 14's outbox will close. + +**Done when:** Replaying a report after restart does not double-count it, database failures do not become false acknowledgements, invalid messages have a durable disposition, and you can explain why QoS 2 does not make database or physical effects exactly once. + +**Next:** [Lesson 14 — Close database/MQTT failure gaps](14-database-mqtt-failure-gaps.md) makes publication intent recoverable with a transactional outbox. diff --git a/teaching/course-source/14-database-mqtt-failure-gaps.md b/teaching/course-source/14-database-mqtt-failure-gaps.md new file mode 100644 index 0000000..712e01b --- /dev/null +++ b/teaching/course-source/14-database-mqtt-failure-gaps.md @@ -0,0 +1,119 @@ +# 🔄 Lesson 14 — Close database/MQTT failure gaps + +## 🎬 Start with the system story + +**❓ Question:** **The API commits a command to PostgreSQL, then the process crashes before publishing it. How will the device ever receive the accepted command?** + +**💬 Answer:** The database must persist both the command and the intent to publish it in the same transaction. A worker can later publish unsent outbox rows. MQTT session storage and a Paho outgoing queue do not replace this application-level record. + +```mermaid +flowchart LR + H[HTTP/API request] --> T[DB transaction] + T --> C[Command row] + T --> O[Outbox row] + O --> W[Retrying publisher worker] + W -->|PUBLISH QoS 1| B[Mosquitto] + B --> D[Device simulator] + D -->|application result| B + B --> I[Ingestion] + I --> R[Persist result and command state] +``` + +The database owns accepted business intent. The outbox worker owns attempts to transmit that intent. The broker owns eligible MQTT session delivery. The device owns its deduplication/effect record. Each layer can repeat work, so stable IDs and idempotency remain necessary. + +## 🪟 1. Draw every failure window + +> **❓ Question:** **What if the publish reaches the broker, but the backend crashes before marking the outbox row sent?** + +Walk each boundary separately and write the durable state before and after a crash: + +| Failure point | Durable state | Recovery behavior | +| --- | --- | --- | +| Before command transaction commits | No accepted command/outbox | Caller can retry using its HTTP idempotency key | +| After commit, before worker publishes | Command and pending outbox row | Worker publishes after restart | +| After PUBLISH, before broker PUBACK | Outbox still pending/attempting | Retry may duplicate; preserve command ID | +| After PUBACK, before outbox marked sent | Broker accepted that hop; row may still be pending | Worker may publish again; device deduplicates | +| After device acts, before its result reaches backend | Device may have acted; backend status is uncertain | Retry/reconcile by stable command ID; do not infer failure from timeout | +| After result commit, before application receipt reaches device | Result is stored; response may be lost | Duplicate request/result returns stored prior outcome | + +`PUBACK` lets the backend record evidence about a publisher-to-broker hop. It is not proof that a device received or completed the command. A timeout after publishing is an unknown business outcome unless the device later supplies evidence. + +## 📤 2. Use a transactional outbox + +> **❓ Question:** **How can the API accept a command and guarantee the intent to publish is not lost in a crash?** + +In one database transaction, create the command row and an outbox row containing the serialized/versioned publication intent, target topic, stable command ID, expiry/deadline, and attempt state. Commit both together. A worker claims pending rows, publishes with a bounded context, records the returned MQTT result, and retries eligible failures with backoff. Keep the same command ID across every retry. + +The outbox closes the “database committed, publish intent forgotten” gap. It does not make the remote publish and local sent marker atomic. If the broker accepts the publication and the backend crashes before updating the row, a later worker can publish again. The device must deduplicate by command ID and replay the prior application result. + +Do not hold a database transaction open while waiting for a network publish. Claim work with a short transaction, publish outside it, then record the attempt outcome. Use a lease/claim strategy so two workers do not continuously publish the same row at once; still assume duplicates can occur around crashes. + +## 🧾 3. Persist device results and idempotency + +> **❓ Question:** **A device result arrives twice, or arrives after the command timed out. Should status regress?** + +Validate the response topic, payload, device identity, command ID, and Correlation Data before applying it. In one transaction, store the result under a unique identity and update command state only through allowed transitions. A duplicate terminal result should not repeat an effect or regress completed/rejected state. A late result can resolve an earlier unknown outcome if it matches the command; preserve the history that a timeout had occurred. + +Classify outcomes explicitly: + +- **Retryable transport failure:** no reliable evidence that the peer accepted the publish; retry within expiry/deadline policy. +- **Permanent rejection:** broker or application reports a non-retryable reason; retain that reason safely. +- **Expired command:** do not publish stale work; mark it expired and expose that outcome. +- **Unknown device outcome:** publication may have arrived, but the application response is missing. Do not claim the device did nothing. +- **Completed/rejected by device:** validated application result is persisted; duplicates do not change the terminal result. + +Correlation Data helps match a response to a request but is not authorization or deduplication. The application command ID and device-side deduplication state remain necessary. + +## 🔀 4. Keep the three queue/state mechanisms distinct + +> **❓ Question:** **Can a file-backed MQTT queue replace the PostgreSQL outbox?** + +No. These mechanisms protect different state: + +| Mechanism | Owner | What it can preserve | What it does not establish | +| --- | --- | --- | --- | +| Transactional outbox | Application database | Business intent and retry state atomically with command state | Device receipt or completion | +| Paho outgoing queue | MQTT client library/application configuration | Publications queued for transport according to that queue's semantics | Atomicity with the business DB transaction or device effect | +| MQTT session state | Client and broker | Protocol/session continuity and eligible queued QoS delivery | Application business state, deduplication, or physical action | + +Review the pinned AutoPaho queue example and persistence API before enabling a file-backed queue. Record exactly where its files live and what a successful enqueue result means. Do not infer broker delivery from local enqueue success. + +## ⏳ 5. Apply retries, expiry, and backpressure + +> **❓ Question:** **Should an old price update or expired ping be published after several hours of retry?** + +Give the command an application deadline and MQTT 5 Message Expiry Interval when appropriate. The worker checks the application deadline before publishing; broker expiry limits queued MQTT delivery after publication. The device checks its own deadline too, because time can be spent in the outbox, network, broker session, and device queue. These are separate clocks and checks. + +Use bounded retry counts or retry windows with backoff and jitter. Stop or park permanently rejected and expired rows. Bound concurrent publishes and database claims; observe pending outbox count, oldest row age, attempts, publish latency, PUBACK results, application response latency, duplicate results, and unknown outcomes. + +## 🧪 6. Inject crash windows + +Run only against the dedicated learning broker and database, with synthetic commands: + +1. Commit a command/outbox row, stop the publisher worker, restart it, and verify the pending row is attempted. +2. Stop the backend immediately after publish but before marking the outbox row sent. Restart and verify the duplicate publication uses the same command ID and produces one simulated effect. +3. Drop the device response after the simulated effect. Confirm status becomes unknown or remains pending according to the documented deadline policy; it must not report definite failure. +4. Expire a command before publish and verify the worker does not send it. +5. Restart the broker and compare broker session recovery with the still-pending application outbox row. + +For each run, record prediction, injected failure, database rows, MQTT packet evidence, device effect counter, final status, and cleanup. Restore the lab by stopping only the project-named learning services and clearing only test data you created. + +### 🧠 Review questions + +1. Which transaction removes the lost-intent gap between accepting a command and publishing it? +2. Why can the device receive a duplicate after the broker already sent PUBACK to the backend? +3. What can a Paho outgoing queue prove, and what can it not prove? +4. Which state is owned by the broker session versus the command database? +5. What should the API report when the device may have acted but no result arrived? + +> [!answer]- 📋 Short answers +> +> 1. Commit the command and outbox intent atomically. +> 2. The backend can crash after PUBACK but before marking the outbox row sent, then publish again after restart. +> 3. It can prove local queue admission according to its API; it cannot prove broker delivery or business completion. +> 4. Broker session state tracks MQTT subscriptions/in-flight/eligible queued delivery; the command database tracks application intent, status, and results. +> 5. An unknown outcome, until validated device evidence resolves it. + +**Done when:** Every outbox crash window has an explainable recovery path, repeated publication causes one simulated effect, expiry prevents stale delivery, and command status never treats PUBACK as device completion. + +**Next:** [Section 15](../../COURSE.md#section-15--split-api-and-ingestion-processes) moves API and ingestion into separate processes while keeping persisted status as the shared source of truth. diff --git a/lessons/16-mqtt5-controls.md b/teaching/course-source/16-mqtt5-controls.md similarity index 92% rename from lessons/16-mqtt5-controls.md rename to teaching/course-source/16-mqtt5-controls.md index 95bc9b0..68e3339 100644 --- a/lessons/16-mqtt5-controls.md +++ b/teaching/course-source/16-mqtt5-controls.md @@ -1,6 +1,6 @@ # Section 16 — MQTT 5 controls you can explain and measure -Study this after Sections 12–15 in [COURSE.md](../COURSE.md), or read it earlier as a conceptual preview. Continue using Paho v0.23.0 and the isolated broker. Each experiment changes one behavior; record the broker/client versions and observed result instead of assuming a field's existence proves complete support. +Study this after Sections 12–15 in [COURSE.md](../../COURSE.md), or read it earlier as a conceptual preview. Continue using Paho v0.23.0 and the isolated broker. Each experiment changes one behavior; record the broker/client versions and observed result instead of assuming a field's existence proves complete support. ## 1. Subscription options change what arrives @@ -28,7 +28,7 @@ An UNSUBSCRIBE removes a broker subscription after its UNSUBACK exchange; it doe **💬 Answer:** The receiving process may have no matching in-memory command record. A shared subscription distributes each matching delivery to a group member; it does not share process memory or guarantee device affinity. Keep response correlation state shared or route responses to a common durable store. -Ordinary subscriptions on two clients each receive a matching publication. With `$share/ingest/lab/v1/devices/+/telemetry`, both clients join the `ingest` group for that filter and the broker selects a group member for a matching delivery. Give each process a unique Client ID; sharing a group is different from sharing a Client ID. +Ordinary subscriptions on two clients each receive a matching publication. Build the base filter with `contracts.DeviceFilter(contracts.KindTelemetry)`, then wrap its string form as `$share/ingest/` plus that filter to create the shared subscription. Both clients join the `ingest` group, and the broker selects a group member for each matching delivery. Convert the final shared filter to `contracts.TopicFilter` when passing it through the lab transport helper, or to `string` at the Paho packet boundary. Give each process a unique Client ID; sharing a group is different from sharing a Client ID. Create two authorized lab consumers, check their SUBACK results, then publish numbered synthetic telemetry. Record which instance handles each ID. Stop one consumer and repeat. Use a fresh lab group/session for the experiment so old offline state does not obscure the result. Verify the broker's shared-subscription support and effective ACL behavior before drawing conclusions. @@ -126,4 +126,4 @@ The inflight quota counts unacknowledged protocol deliveries, not all work the a Sources: [MQTT 5 specification](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html) and pinned local `paho/cp_subscribe.go`, `paho/cp_unsubscribe.go`, `paho/cp_publish.go`, `paho/cp_connect.go`, `paho/cp_connack.go`, and `autopaho/backoff.go` under `references/paho.golang-v0.23.0/`. -Next: Section 17 in [COURSE.md](../COURSE.md). +Next: Section 17 in [COURSE.md](../../COURSE.md). diff --git a/teaching/course-source/17a-advanced-devops-lifecycle.md b/teaching/course-source/17a-advanced-devops-lifecycle.md new file mode 100644 index 0000000..8219c0a --- /dev/null +++ b/teaching/course-source/17a-advanced-devops-lifecycle.md @@ -0,0 +1,180 @@ +# Section 17A — Advanced DevOps service lifecycle + +> [!toc]- Contents +> +> - [Purpose and boundaries](#purpose-and-boundaries) +> - [The service lifecycle](#the-service-lifecycle) +> - [1. Map ownership and failure domains](#1-map-ownership-and-failure-domains) +> - [2. Build trustworthy CI gates](#2-build-trustworthy-ci-gates) +> - [3. Package and promote artifacts](#3-package-and-promote-artifacts) +> - [4. Configure each environment safely](#4-configure-each-environment-safely) +> - [5. Release compatible services and schemas](#5-release-compatible-services-and-schemas) +> - [6. Deploy, observe, and recover](#6-deploy-observe-and-recover) +> - [7. Operate through incidents and change](#7-operate-through-incidents-and-change) +> - [Capstone exercise](#capstone-exercise) +> - [Self-check](#self-check) + +## Purpose and boundaries + +This lesson is for a backend engineer with junior-level DevOps experience who wants to own a service through its full delivery and operational lifecycle. It builds on the backend, simulator, PostgreSQL state, MQTT broker, and runbooks from earlier sections. + +The goal is to practice production-grade reasoning in an isolated learning environment. Do not deploy to Robot Market production or connect real vending machines. Kubernetes is not required: use the lab's Docker Compose setup to practice the same lifecycle decisions, then compare how an orchestrator would implement them. + +The lesson does not prescribe a CI vendor, cloud, or deployment platform. The practices apply across those choices. Each decision should name its owner, evidence, failure mode, and recovery action. + +## The service lifecycle + +**❓ Question:** What must be true before a code change can safely become a running service? + +**💬 Answer:** The change needs reviewable source, automated checks, a known build artifact, environment-specific configuration, an explicit rollout, and evidence that the service works afterward. Operators also need a way to detect failure and recover without guessing which state was lost. + +```mermaid +flowchart LR + S[Source change] --> C[CI checks] + C --> A[Versioned artifact] + A --> P[Promote same artifact] + P --> D[Deploy with configuration] + D --> V[Verify health and behavior] + V --> O[Observe service indicators] + O --> R[Recover, roll forward, or roll back] + R --> L[Review and improve] + L --> S +``` + +Promotion means moving the same artifact through environments. Rebuilding separately for each environment makes it harder to know whether the tested bits are the deployed bits. Configuration and secrets change by environment; the application artifact should not. + +## 1. Map ownership and failure domains + +**❓ Question:** If the ingestion process restarts, which facts can it reconstruct, and which system owns them? + +Start with a state inventory before designing deployment. A successful MQTT reconnection does not restore application data, and a database restart does not restore a broker's session state. + +| State or responsibility | Owner in this course | Recovery evidence | +| --- | --- | --- | +| Durable commands, report results, deduplication | PostgreSQL | Query after application restart; replay a duplicate safely | +| MQTT subscriptions and broker session state | Broker, subject to session and persistence configuration | Session experiment and broker restart check | +| Pending callback work and local device view | Service process memory until explicitly persisted | Process restart shows what disappears | +| Desired configuration snapshot | Application database or retained MQTT state, as explicitly designed | Compare the authoritative revision after restart | +| Credentials and environment settings | Deployment configuration/secret source | Redacted effective configuration and rotation procedure | +| Build and release identity | Source revision and artifact registry | Map a running image digest back to source and pipeline result | + +Draw the system's dependencies and failure domains: API, ingestion, PostgreSQL, broker, simulator/device, and deployment environment. For each, record what happens when it is unavailable, whether work queues or fails, and which owner responds. + +Do not use “MQTT is up” or “the container is running” as a complete health statement. A process can run while subscriptions are missing, the database is unavailable, or accepted work is stuck. + +## 2. Build trustworthy CI gates + +**❓ Question:** Which checks are fast and repeatable on every change, and which require a controlled environment? + +Organize checks by the evidence they provide: + +| Gate | Example evidence | What it does not prove | +| --- | --- | --- | +| Formatting and static checks | `gofmt`, `go vet`, lint results | Correct runtime behavior | +| Unit tests | Contract, state transition, and retry behavior | Broker or database interoperability | +| Concurrency checks | `go test -race ./...` | Absence of all logical races or production overload | +| Integration tests | Isolated PostgreSQL and Mosquitto tests | Production network, credentials, or capacity | +| Acceptance checks | API request reaches simulator and accurate result is queryable | Safe deployment or production device compatibility | +| Image checks | Build, user, metadata, dependency and vulnerability review | Runtime configuration or readiness | + +Make each gate deterministic where possible: pin tool and dependency versions, use synthetic data, isolate test infrastructure, set timeouts, and collect logs on failure. Keep broker/device acceptance checks separate from fast unit tests so their failures identify the right layer. + +A green pipeline is evidence for the checks it ran, not a guarantee of safety. Record skipped checks and required human review explicitly. A failing quality or security gate should stop artifact promotion until the failure is understood or an authorized exception is documented. + +## 3. Package and promote artifacts + +**❓ Question:** How can an operator identify exactly which program is running and reproduce that artifact? + +Build versioned service images from a pinned Go toolchain and dependencies. Prefer a multi-stage build that compiles in a build stage and copies only the required binary and runtime assets into the final image. Run as a non-root user, use a narrow writable filesystem, and include no source credentials, `.env` files, or lab password files in image layers. + +Attach traceable metadata to each artifact: source revision, build job, dependency versions, and immutable image digest. A human-readable version tag helps people; the digest identifies the exact content. Promote the same digest from test to staging. Do not rebuild it with different build arguments to “make a staging image.” + +Treat base-image and Go dependency updates as planned changes: review release notes, run the normal gates, scan for known vulnerabilities, and retain the prior deployable artifact for recovery. A scan finding needs triage based on affected code, exposure, and available fix; a scan alone is not a complete security review. + +For this exercise, add a deployment definition for the learning services and inspect the resolved configuration before starting them. Confirm the final image user, entrypoint, signal behavior, mounted paths, and exposed ports. Keep the broker listener on loopback unless the experiment explicitly requires another network boundary. + +## 4. Configure each environment safely + +**❓ Question:** What should change between local, test, and staging environments, and what should stay identical? + +The artifact stays identical. Broker addresses, database endpoints, resource limits, and secret references vary by environment. Use the configuration loader's validation before opening MQTT or database connections. Log a redacted configuration summary so an operator can diagnose the effective settings without revealing passwords, tokens, or private keys. + +Use distinct identities for API, ingestion, broker, database, and deployment automation. Grant each only the access it needs. Supply secrets at runtime through the environment's secret mechanism; never place real values in source, image layers, test output, or resolved configuration shared in an issue. + +Plan credential rotation as an operational change: issue a replacement, allow the intended overlap, update consumers, verify successful connections and authorization, then revoke the old credential. Define how to recover if one process fails to adopt the new value. For broker ACL changes, test both allowed and denied topics before promotion. + +Configuration validation should distinguish a missing or invalid setting from a transient dependency outage. Invalid configuration should fail clearly before serving traffic; temporary broker/database failure should follow the documented retry and readiness policy. + +## 5. Release compatible services and schemas + +**❓ Question:** How do you roll out a database change when old and new service versions may run at the same time? + +Use an expand/migrate/contract sequence: + +1. **Expand:** add a schema capability that old code can tolerate, such as a nullable column or new table. +2. **Migrate:** deploy code that can work with the old and expanded schema; backfill data in bounded, observable batches if needed. +3. **Switch:** verify the new code path and data before relying on it. +4. **Contract:** remove old schema only after all old binaries and rollback paths no longer need it. + +Database rollback is not always the inverse of a schema migration. Destructive changes or new data may make old code incompatible. State whether a release can roll back, needs a forward fix, or requires restoring data; rehearse the selected path with synthetic data. + +Use immutable artifacts and explicit release records. A release should identify source revision, image digests, schema version, configuration change, approvals, rollout time, verification evidence, and operator. Keep changes small enough to identify which change caused an incident. + +## 6. Deploy, observe, and recover + +**❓ Question:** What signals show that a new version is ready to receive work, and what should happen to accepted work during termination? + +Define health states per process: + +- **Liveness:** the process can make progress; a failed check may justify restarting it. +- **Readiness:** the process is prepared to receive its assigned traffic or messages; a failed check should remove it from new work where the platform supports that behavior. +- **Dependency status:** broker and database reachability, reported separately so an operator can see which dependency is failing. + +Readiness should reflect required initialization, not merely an open TCP port. For ingestion, verify required subscriptions and durable-store availability before claiming readiness. During shutdown, stop admitting new work, drain accepted work within a deadline, preserve work that cannot finish, and disconnect cleanly. Document what happens when the deadline expires. + +Roll out progressively where the platform permits it. Set a small release scope, monitor error and latency indicators, and define pause/rollback thresholds before deployment. For multiple ingestion replicas, include shared-subscription behavior, duplicate handling, ordering assumptions, and durable idempotency in the rollout plan. + +Define useful service indicators: successful API requests, MQTT reconnects, subscription readiness, queue depth and age, command completion/unknown outcomes, report processing lag, database errors, and resource pressure. Avoid unbounded device or command identifiers as metric labels. Correlate logs with stable IDs while redacting secrets and untrusted payloads. + +For state recovery, define example recovery-point and recovery-time objectives (RPO/RTO). Backups only count when restore works: rehearse PostgreSQL restore and inspect broker persistence/session behavior separately. Record which data can be rebuilt, which work may replay, and how duplicate effects are prevented. + +## 7. Operate through incidents and change + +**❓ Question:** During an incident, what should the responder establish before restarting services or changing configuration? + +Follow a repeatable sequence: + +1. **Detect:** identify the user-visible symptom and the alert or report that exposed it. +2. **Scope:** determine affected process, devices, commands/reports, time window, and release version. +3. **Stabilize:** stop harmful retries or further rollout; preserve evidence and durable work. +4. **Diagnose:** compare application, broker, database, network, and device evidence. Separate transport acknowledgement from business completion. +5. **Recover:** choose restart, rollback, forward fix, replay, or restore based on state ownership and data compatibility. +6. **Verify:** confirm service indicators recover and affected commands/reports reach a known state. +7. **Learn:** record timeline, cause, contributing conditions, and a follow-up change with an owner. + +Runbooks should state commands in context, expected evidence, access required, risks, and restoration steps. Keep incident notes factual and avoid credentials or private payloads. Schedule restore drills, dependency upgrades, certificate rotation, and access reviews rather than waiting for an incident to reveal they are overdue. + +## Capstone exercise + +Prepare and release a simulated change that adds a harmless command result field or compatible database column. Use only the isolated lab. + +Before starting, predict the behavior if the API is restarted during rollout, PostgreSQL is unavailable, a new image fails readiness, and rollback is requested after the schema migration. Then: + +1. Draw the pipeline and state-ownership diagram. +2. Define automated gates and the evidence each gate produces. +3. Build and identify one immutable service artifact. +4. Deploy it with lab-only configuration and verify readiness, a command flow, and report persistence. +5. Inject one failure, pause rollout, recover using the documented path, and verify durable state. +6. Record the release and incident evidence, including what remains unverified outside the lab. + +**Deliverables:** pipeline diagram, release checklist, artifact identity, redacted deployment configuration, rollout/rollback decision, recovery record, and a short incident review. + +## Self-check + +1. Why is promoting one image digest safer than rebuilding for each environment? +2. Why can a successful database backup still leave recovery unproven? +3. Which work should an ingestion readiness check confirm before the service accepts messages? +4. Why might rolling back a binary be unsafe after a database migration? +5. Which evidence distinguishes a broker delivery acknowledgement from a completed vending command? + +**Answer guide:** A digest identifies the same tested bits across environments; a restore drill proves backup usability; readiness checks required subscriptions and storage dependencies; schema/data changes can make old code incompatible; a device/application result and persisted command state provide business evidence beyond MQTT acknowledgement. diff --git a/lessons/README.md b/teaching/course-source/README.md similarity index 77% rename from lessons/README.md rename to teaching/course-source/README.md index 47ab90e..fd1fc47 100644 --- a/lessons/README.md +++ b/teaching/course-source/README.md @@ -1,4 +1,4 @@ -# MQTT in Go: lessons and concept coverage +# MQTT in Go: Markdown course source Begin with [Lesson 0 — The broker, Go backend, and device lifecycle](00-system-lifecycle.md) for the complete system story. Lessons 1–8 open with a lifecycle question and diagram where useful, then provide a guiding question before each implementation step. @@ -6,11 +6,14 @@ Broker deployment and administration are part of the learning goals. Read [Broke Follow these lessons in order from the learning project root. Read the concept and packet flow first, predict the experiment, then implement and compare the result. Go and basic Docker syntax are assumed; explanations focus on MQTT behavior and operational consequences. Full early examples remain available as reference solutions. +The shared lab topic contract lives in `internal/contracts/topic.go`: use its concrete topic builders for publishing, fleet filters for backend subscriptions, exact filters for simulator subscriptions, and parser for inbound routing. Mosquitto ACL text remains a separate authorization configuration. + 1. [Set up the broker and MQTT 5 lab](01-lab-setup.md) 2. [Write and understand a Go MQTT 5 connection](02-go-connection.md) 3. [Add typed TOML and koanf configuration](03-typed-configuration.md) 4. [Publish, subscribe, and build a device simulator](04-publish-subscribe-simulator.md) 5. [Design contracts and validate inbound messages](05-contracts-validation.md) +5A. [Turn validated messages into backend state](05a-backend-message-processing.md) 6. [Manage callbacks, concurrency, and cancellation](06-concurrency-cancellation.md) 7. [Build reliable command and response behavior](07-command-response.md) 8. [Expose device commands through HTTP](08-http-commands.md) @@ -18,8 +21,12 @@ Follow these lessons in order from the learning project root. Read the concept a 10. [Expiry, presence, and retained state](10-expiry-presence-state.md) 11. [MQTT security boundaries](11-security-boundaries.md) 11B. [Provision device ACLs with Mosquitto Dynamic Security](11b-dynamic-security.md) — optional implementation extension to Section 11 +12. [Implement representative vending workflows](12-vending-workflows.md) +13. [Persist reports and command state in PostgreSQL](13-persist-reports-command-state.md) +14. [Close database/MQTT failure gaps with an outbox](14-database-mqtt-failure-gaps.md) +17A. [Practice the advanced DevOps service lifecycle](17a-advanced-devops-lifecycle.md) — standalone lifecycle and release practices lesson -Continue with course Sections 12–15 after the security lessons, then implement [Section 16 — MQTT 5 controls](16-mqtt5-controls.md). Course sections 12–15 and 17–18 remain guided checklists, not complete reference programs. +The interactive HTML sequence in `../lessons/` continues beyond the Markdown implementation lessons: [Lesson 15 — split API and ingestion](../lessons/0015-process-boundaries.html), [Lesson 17 — operate and diagnose](../lessons/0017-operations-diagnostics.html), and [Lesson 18 — backend handover](../lessons/0018-backend-handover.html) turn those `COURSE.md` checklists into guided exercises. Section 16 has both a [Markdown source lesson](16-mqtt5-controls.md) and an [interactive guide](../lessons/0016-mqtt5-controls.html). Section 17A is a separate Markdown lesson for advanced DevOps lifecycle practices. Paho is pinned to **v0.23.0**, the selected course baseline. Use the matching checkout under `references/paho.golang-v0.23.0/`. This release requires Go 1.24 or newer; the lessons' Go 1.25 baseline is compatible. @@ -31,7 +38,7 @@ The complete Go examples were extracted into a separate temporary module and che An isolated temporary Mosquitto 2.0.22 lab verified the documented Compose configuration, password-file permissions, ACL file loading, initial subscriptions, Go heartbeats, QoS 1 ping/pong, incorrect-password rejection, and subscription restoration after broker restart. The temporary broker and clients were removed afterward. The lesson exercises still require you to verify behavior in your own implementation; this check does not prove production reliability. -Sections 5–11 and 16 are guided exercises, not complete tested reference programs. Their implementation checks are for you to run after writing the code. +Lessons 5–11, 12–14, and 16 are guided exercises, not complete tested reference programs. Their implementation checks are for you to run after writing the code. ## Verification for the 2026-10-08 teaching review @@ -48,6 +55,7 @@ Checked local Markdown links, source paths, code fences, and expandable answer b | QoS 0/1/2, per-hop acknowledgements, DUP and business IDs | 4 and 7 | Predict no-subscriber, lower-QoS, and duplicate outcomes | | Callback/ACK order, manual acknowledgement, bounded queues | 6 | Show the RAM loss window and measurable overload | | Payload validation, Content Type, Payload Format | 5 | Reject valid-looking but invalid messages before state changes | +| Validated message to application service and device view | 5A | Update last-seen state only after validation; distinguish freshness from health and transport | | Response Topic, Correlation Data, timeouts, idempotency | 7 and 8 | Match concurrent results and retry without another effect | | Clean Start, Session Expiry, Session Present, recovery stores | 9 | Predict network versus process restart outcomes | | Message Expiry, retained deletion, Will/Will Delay, freshness | 10 | Observe stale command suppression and presence limitations | @@ -61,4 +69,4 @@ Coverage means these concepts are explained and have exercises. It does not mean For each experiment, write: **prediction → operation and reason → observed packet/application evidence → explanation → restoration step**. Expand the answer blocks only after explaining the outcome yourself. -See [COURSE.md](../COURSE.md) for the complete sequence and the connection to Robot Market's backend. +See [COURSE.md](../../COURSE.md) for the complete sequence and the connection to Robot Market's backend. diff --git a/lessons/broker-operations.md b/teaching/course-source/broker-operations.md similarity index 100% rename from lessons/broker-operations.md rename to teaching/course-source/broker-operations.md diff --git a/teaching/learning-records/0001-reported-course-progress.md b/teaching/learning-records/0001-reported-course-progress.md new file mode 100644 index 0000000..d7fc7d9 --- /dev/null +++ b/teaching/learning-records/0001-reported-course-progress.md @@ -0,0 +1,5 @@ +# Reported course progress + +The learner initially reported studying through the end of Section 5 and requested the added Lesson 5A followed by Lessons 6–8. On 2026-10-19, the learner reported finishing through Lesson 5A and requested preparation of the remaining lessons through the end of the Section 18 course plan. This establishes the reported progress and requested sequence, not demonstrated mastery of any concept; use the short predictions and exercises to check understanding before building on it. + +HTML guides were added for Lessons 9–18, with Lesson 11B as an optional Dynamic Security extension. Their existence is teaching material, not evidence that the learner has completed them. diff --git a/teaching/lessons/0005-contract-validation.html b/teaching/lessons/0005-contract-validation.html new file mode 100644 index 0000000..5eeecce --- /dev/null +++ b/teaching/lessons/0005-contract-validation.html @@ -0,0 +1,670 @@ + + + + + + Lesson 5 — Validate the Message Before Acting + + + + +
+

Go MQTT Vending Backend · Lesson 5 · 7 minutes

+

Validate the message before acting

+

A broker-routed message still contains untrusted bytes. Build the boundary that stops a wrong or malformed vending report before it can change backend state.

+
🎯 One skill: validate route and body together🧠 Predict, then check🧪 No broker required
+ +
+

❓ Start with the story

+

A publication arrives on lab/v1/devices/device-001/sales. Its valid JSON body claims device_id: device-002. Should the backend accept it?

+

Say what could go wrong before revealing the model: the route and the body disagree about which machine owns this report.

+
+ +

🗺️ First, see the full boundary

+
+
Broker delivers
topic + bytes
→ +
Parse route
device + kind
→ +
Decode contract
shape + values
→ +
Only then
application action
+
+

The topic tells the backend where the broker routed the message. The payload claims what the message means. The backend must compare them; neither a matching topic nor valid JSON authenticates the sender. Mosquitto credentials and ACLs are a separate access-control boundary.

+ +

🔎 Read the code by responsibility

+
+
+

📄 What the files do

+

internal/contracts/topic.go defines the topic hierarchy, publish-name/filter types, builders, and parser. message.go defines payload structures and their checks. decode.go limits and decodes JSON, then invokes the correct contract validator.

+
+
+

📥 `Decode` — short summary

+

Turns bytes into one typed message only after the size, JSON shape, topic identity, schema, timestamp, and message-specific rules pass.

+
+
+
+

🧭 Function overview: follow the boundary in order

+

ParseTopic summary: checks the shared topicPrefix, then validates the remaining device ID and message kind.

+

Decode[T] summary: checks payload size and JSON shape, decodes one typed value, then calls that value's Validate method.

+

Validate summary: confirms that the message type matches the topic kind, shared envelope fields are valid, topic and body device IDs agree, and message-specific values are allowed.

+

🔎 Details: each layer narrows what the next layer must trust

+
    +
  1. ParseTopic checks the configured prefix first, then splits and validates only the two remaining levels. Changing the shared prefix does not require rewriting the parser's level checks. It rejects malformed routes before selecting a payload type and does not prove which client sent the packet.
  2. +
  3. Decode rejects oversized bytes before allocating/parsing JSON, disallows unknown object fields, and rejects a second trailing JSON value. Only then does it construct T.
  4. +
  5. validateEnvelope, called by each contract's Validate, checks schema version, safe IDs, topic/body identity agreement, and timestamp bounds.
  6. +
  7. Each concrete Validate method adds its own checks: telemetry temperature, supported command, response status, or sales-line values. A structurally valid message can still fail these domain rules.
  8. +
+

The backend entrypoint must stop on any returned error; only the typed value returned by successful Decode is eligible for application logic. Authentication and authorization still come from the broker/client configuration, not these payload checks.

+
+
+

🧩 Details: route first, decode second

+
topic, err := contracts.ParseTopic(message.Topic)
+if err != nil {
+    return err // malformed route: no body handler runs
+}
+
+report, err := contracts.Decode[contracts.SalesReport](
+    message.Payload, topic, time.Now().UTC(),
+)
+if err != nil {
+    return err // bad shape, identity, schema, time, or values
+}
+
+log.Printf("valid sales report device=%s report=%s", report.DeviceID, report.ReportID)
+

Why this order? The parsed topic chooses the expected contract type. `Decode` rejects oversized input before parsing JSON, rejects unknown fields and extra JSON values, and calls `Validate`. Only a valid typed value reaches application handling. This section logs the validated report; Section 13 later persists sales.

+

This focused fragment shows the order for the validation-stage handler. The current cmd/backend/main.go still logs raw payloads; the complete target entrypoints and contract files are included in the full-code section below.

+
+ +

🧪 Check your model

+
+
+ 1. What do topic and payload prove together? + + + +
+ +
+
+
+ 2. What does the 16 KiB application limit bound? + + + +
+ +
+
+
+ 3. What can an invalid message still receive? + + + +
+ +
+ +

🛠️ Your tiny practice

+
+

Predict which check rejects each input before looking at the implementation:

+
    +
  1. Correct sales topic, body has an unknown field.
  2. +
  3. Correct topic, valid JSON, body names a different device.
  4. +
  5. Correct topic and identity, but one sale line has quantity zero.
  6. +
+

Write the validation stage for each: topic parse, JSON/schema, shared identity, or message-specific value. Then compare with the complete Section 5 contract code ↗.

+
+ + +
📦 Section 5 complete implementation files

Complete source files for this lesson are included here. The code blocks earlier in the lesson focus on the behavior being taught; use these full files when implementing it.

📄 internal/contracts/topic.go

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) {
+	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
+}
+

📄 internal/contracts/message.go

package contracts
+
+import (
+	"fmt"
+	"time"
+)
+
+const SchemaVersion = 1
+
+// MQTTMessage is implemented by each versioned message contract.
+// Validation uses the parsed route and caller-supplied time for deterministic checks.
+type MQTTMessage interface {
+	Validate(topic Topic, now time.Time) error
+}
+
+// These types define the JSON fields shared by backend and simulator.
+type Heartbeat struct {
+	SchemaVersion int      `json:"schema_version"`
+	MessageID     string   `json:"message_id"`
+	DeviceID      string   `json:"device_id"`
+	OccurredAt    string   `json:"occurred_at"`
+	Temperature   *float64 `json:"temperature,omitempty"`
+}
+
+type Command struct {
+	SchemaVersion int    `json:"schema_version"`
+	MessageID     string `json:"message_id"`
+	DeviceID      string `json:"device_id"`
+	OccurredAt    string `json:"occurred_at"`
+	CommandID     string `json:"command_id"`
+	Command       string `json:"command"`
+}
+
+type CommandResult struct {
+	SchemaVersion int    `json:"schema_version"`
+	MessageID     string `json:"message_id"`
+	DeviceID      string `json:"device_id"`
+	OccurredAt    string `json:"occurred_at"`
+	CommandID     string `json:"command_id"`
+	Status        string `json:"status"`
+	Detail        string `json:"detail,omitempty"`
+}
+
+type SalesLine struct {
+	SKU         string `json:"sku"`
+	Quantity    int64  `json:"quantity"`
+	AmountMinor int64  `json:"amount_minor"`
+}
+
+type SalesReport struct {
+	SchemaVersion int         `json:"schema_version"`
+	ReportID      string      `json:"report_id"`
+	DeviceID      string      `json:"device_id"`
+	OccurredAt    string      `json:"occurred_at"`
+	Items         []SalesLine `json:"items"`
+}
+
+// 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 {
+		return fmt.Errorf("unsupported schema_version %d", version)
+	}
+	if !validID(messageID) || !validID(deviceID) {
+		return fmt.Errorf("message_id and device_id must be valid IDs")
+	}
+	if topic.DeviceID != deviceID {
+		return fmt.Errorf("topic device %q does not match payload device_id %q", topic.DeviceID, deviceID)
+	}
+	stamp, err := time.Parse(time.RFC3339, occurredAt)
+	if err != nil {
+		return fmt.Errorf("occurred_at must be RFC3339: %w", err)
+	}
+	if stamp.After(now.Add(30 * time.Second)) {
+		return fmt.Errorf("occurred_at is more than 30 seconds in the future")
+	}
+	return nil
+}
+
+// Each Validate method adds checks specific to one message kind.
+func (m Heartbeat) Validate(topic Topic, now time.Time) error {
+	if topic.Kind != KindTelemetry {
+		return fmt.Errorf("heartbeat requires telemetry topic")
+	}
+	if err := validateEnvelope(topic, m.SchemaVersion, m.MessageID, m.DeviceID, m.OccurredAt, now); err != nil {
+		return err
+	}
+	if m.Temperature != nil && (*m.Temperature < -50 || *m.Temperature > 100) {
+		return fmt.Errorf("temperature is outside the supported range")
+	}
+	return nil
+}
+
+func (m Command) Validate(topic Topic, now time.Time) error {
+	if topic.Kind != KindCommands {
+		return fmt.Errorf("command requires commands topic")
+	}
+	if err := validateEnvelope(topic, m.SchemaVersion, m.MessageID, m.DeviceID, m.OccurredAt, now); err != nil {
+		return err
+	}
+	if !validID(m.CommandID) || m.Command != "ping" {
+		return fmt.Errorf("command_id is invalid or command is unsupported")
+	}
+	return nil
+}
+
+func (m CommandResult) Validate(topic Topic, now time.Time) error {
+	if topic.Kind != KindResponses {
+		return fmt.Errorf("command result requires responses topic")
+	}
+	if err := validateEnvelope(topic, m.SchemaVersion, m.MessageID, m.DeviceID, m.OccurredAt, now); err != nil {
+		return err
+	}
+	if !validID(m.CommandID) || (m.Status != "accepted" && m.Status != "completed" && m.Status != "rejected") {
+		return fmt.Errorf("command result has invalid command_id or status")
+	}
+	return nil
+}
+
+func (m SalesReport) Validate(topic Topic, now time.Time) error {
+	if topic.Kind != KindSales {
+		return fmt.Errorf("sales report requires sales topic")
+	}
+	if err := validateEnvelope(topic, m.SchemaVersion, m.ReportID, m.DeviceID, m.OccurredAt, now); err != nil {
+		return err
+	}
+	if len(m.Items) == 0 || len(m.Items) > 100 {
+		return fmt.Errorf("sales report must contain 1 to 100 items")
+	}
+	for i, item := range m.Items {
+		if !validID(item.SKU) || item.Quantity <= 0 || item.AmountMinor < 0 {
+			return fmt.Errorf("item %d has invalid SKU, quantity, or amount_minor", i)
+		}
+	}
+	return nil
+}
+
+

📄 internal/contracts/decode.go

package contracts
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"time"
+)
+
+const MaxPayloadBytes = 16 * 1024
+
+// Decode bounds input, rejects unknown fields, requires one JSON value,
+// and validates it against the topic before returning it.
+func Decode[T MQTTMessage](payload []byte, topic Topic, now time.Time) (T, error) {
+	var value T
+	if len(payload) > MaxPayloadBytes {
+		return value, fmt.Errorf("payload exceeds %d bytes", MaxPayloadBytes)
+	}
+	decoder := json.NewDecoder(bytes.NewReader(payload))
+	decoder.DisallowUnknownFields()
+	if err := decoder.Decode(&value); err != nil {
+		return value, fmt.Errorf("decode JSON: %w", err)
+	}
+	var trailing any
+	// Decode reads one JSON value at a time. A second call distinguishes
+	// whitespace (EOF) from a second value or malformed trailing bytes.
+	if err := decoder.Decode(&trailing); err != io.EOF {
+		if err == nil {
+			return value, fmt.Errorf("payload contains more than one JSON value")
+		}
+		return value, fmt.Errorf("trailing data after JSON value: %w", err)
+	}
+	if err := value.Validate(topic, now); err != nil {
+		return value, fmt.Errorf("validate message: %w", err)
+	}
+	return value, nil
+}
+
+

📄 internal/contracts/decode_test.go

package contracts
+
+import (
+	"strings"
+	"testing"
+	"time"
+)
+
+// Topic tests reject malformed routes and unsupported message kinds.
+func TestParseTopic(t *testing.T) {
+	topic, err := ParseTopic("lab/v1/devices/device-001/sales")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if topic.DeviceID != "device-001" || topic.Kind != KindSales {
+		t.Fatalf("unexpected topic: %#v", topic)
+	}
+	for _, invalid := range []string{"lab/v1/devices/+/sales", "lab/v1/devices/device-001/unknown", "lab/v1/devices/device-001/sales/extra"} {
+		if _, err := ParseTopic(invalid); err == nil {
+			t.Errorf("ParseTopic(%q) succeeded", invalid)
+		}
+	}
+}
+
+// These cases cover strict JSON shape and single-value decoding.
+func TestDecodeRejectsUnknownAndTrailingValues(t *testing.T) {
+	topic := Topic{DeviceID: "device-001", Kind: KindTelemetry}
+	now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
+	if _, err := Decode[Heartbeat]([]byte(`{"schema_version":1,"unknown":true}`), topic, now); err == nil {
+		t.Fatal("unknown field was accepted")
+	}
+	if _, err := Decode[Heartbeat]([]byte(`{"schema_version":1,"message_id":"m-1","device_id":"device-001","occurred_at":"2026-10-13T12:00:00Z"} {"message_id":"m-2"}`), topic, now); err == nil {
+		t.Fatal("trailing JSON value was accepted")
+	}
+}
+
+// This case ensures the application limit is applied before decoding.
+func TestDecodeRejectsOversizedPayload(t *testing.T) {
+	payload := []byte(`"` + strings.Repeat("x", MaxPayloadBytes) + `"`)
+	if _, err := Decode[Heartbeat](payload, topic, now); err == nil {
+		t.Fatal("oversized payload was accepted")
+	}
+}
+
+// A body cannot override the device selected by the topic.
+func TestCommandRejectsTopicIdentityMismatch(t *testing.T) {
+	topic, err := ParseTopic("lab/v1/devices/device-001/commands")
+	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"}
+	if err := command.Validate(topic, time.Now().UTC()); err == nil {
+		t.Fatal("topic/body identity mismatch was accepted")
+	}
+}
+
+

📄 cmd/backend/main.go — validation-stage target

package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"example.com/mqtt5-learning/internal/config"
+	"example.com/mqtt5-learning/internal/contracts"
+	"example.com/mqtt5-learning/internal/mqtt"
+)
+
+func main() {
+	if err := run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func run() error {
+	cfg, err := config.Load(os.Args[1:])
+	if err != nil {
+		return fmt.Errorf("load config: %w", err)
+	}
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry)
+	if err != nil { return err }
+	responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses)
+	if err != nil { return err }
+	salesFilter, err := contracts.DeviceFilter(contracts.KindSales)
+	if err != nil { return err }
+	commandTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindCommands)
+	if err != nil { return err }
+	// Start subscriptions before handling any delivered message.
+	inbox := make(chan mqtt.Message, 16)
+	manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{telemetryFilter, responsesFilter, salesFilter}, inbox)
+	if err != nil {
+		return fmt.Errorf("start MQTT: %w", err)
+	}
+	defer mqtt.Stop(manager)
+
+	ticker := time.NewTicker(10 * time.Second)
+	defer ticker.Stop()
+	sequence := 0
+	for {
+		select {
+		case <-ctx.Done():
+			return nil
+		case message := <-inbox:
+			if err := handleMessage(message); err != nil {
+				log.Printf("rejected topic=%q reason=%v", message.Topic, err)
+			}
+		case <-ticker.C:
+			sequence++
+			id := fmt.Sprintf("ping-%d-%d", time.Now().UTC().Unix(), sequence)
+			command := contracts.Command{SchemaVersion: contracts.SchemaVersion, MessageID: id, DeviceID: cfg.MQTT.DeviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: id, Command: "ping"}
+			payload, err := json.Marshal(command)
+			if err != nil {
+				return fmt.Errorf("encode command: %w", err)
+			}
+			if err := mqtt.Publish(ctx, manager, commandTopic, payload); err != nil {
+				log.Printf("command publish failed or uncertain id=%s: %v", id, err)
+			}
+		}
+	}
+}
+
+func handleMessage(message mqtt.Message) error {
+	// Parse and validate before a message can reach application handling.
+	topic, err := contracts.ParseTopic(message.Topic)
+	if err != nil {
+		return err
+	}
+	now := time.Now().UTC()
+	switch topic.Kind {
+	case contracts.KindTelemetry:
+		value, err := contracts.Decode[contracts.Heartbeat](message.Payload, topic, now)
+		if err != nil {
+			return err
+		}
+		log.Printf("valid heartbeat device=%s message=%s", value.DeviceID, value.MessageID)
+	case contracts.KindResponses:
+		value, err := contracts.Decode[contracts.CommandResult](message.Payload, topic, now)
+		if err != nil {
+			return err
+		}
+		log.Printf("command result device=%s command=%s status=%s", value.DeviceID, value.CommandID, value.Status)
+	case contracts.KindSales:
+		value, err := contracts.Decode[contracts.SalesReport](message.Payload, topic, now)
+		if err != nil {
+			return err
+		}
+		log.Printf("valid sales report device=%s report=%s items=%d", value.DeviceID, value.ReportID, len(value.Items))
+	default:
+		return fmt.Errorf("backend does not consume topic kind %q", topic.Kind)
+	}
+	return nil
+}
+
+

📄 cmd/simulator/main.go — contract-stage target

package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"example.com/mqtt5-learning/internal/config"
+	"example.com/mqtt5-learning/internal/contracts"
+	"example.com/mqtt5-learning/internal/mqtt"
+	"github.com/eclipse/paho.golang/autopaho"
+)
+
+func main() {
+	if err := run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func run() error {
+	cfg, err := config.Load(os.Args[1:])
+	if err != nil {
+		return fmt.Errorf("load config: %w", err)
+	}
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	// Publish this device's heartbeat and subscribe only to its command topic.
+	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 }
+	responseTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindResponses)
+	if err != nil { return err }
+	inbox := make(chan mqtt.Message, 16)
+	manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{commandFilter}, inbox)
+	if err != nil {
+		return fmt.Errorf("start MQTT: %w", err)
+	}
+	defer mqtt.Stop(manager)
+	ticker := time.NewTicker(3 * time.Second)
+	defer ticker.Stop()
+	sequence := 0
+	for {
+		select {
+		case <-ctx.Done():
+			return nil
+		case <-ticker.C:
+			sequence++
+			heartbeat := contracts.Heartbeat{SchemaVersion: contracts.SchemaVersion, MessageID: fmt.Sprintf("heartbeat-%d-%d", time.Now().Unix(), sequence), DeviceID: cfg.MQTT.DeviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339)}
+			payload, err := json.Marshal(heartbeat)
+			if err != nil {
+				return fmt.Errorf("encode heartbeat: %w", err)
+			}
+			if err := mqtt.Publish(ctx, manager, telemetryTopic, payload); err != nil {
+				log.Printf("heartbeat publish: %v", err)
+			}
+		case message := <-inbox:
+			if message.Retain {
+				log.Printf("reject retained command topic=%q", message.Topic)
+				continue
+			}
+			if err := respond(ctx, manager, cfg.MQTT.DeviceID, message); err != nil {
+				log.Printf("reject command topic=%q reason=%v", message.Topic, err)
+			}
+		}
+	}
+}
+
+func respond(ctx context.Context, manager *autopaho.ConnectionManager, deviceID string, message mqtt.Message) error {
+	// Check route and body identity before constructing a command result.
+	topic, err := contracts.ParseTopic(message.Topic)
+	if err != nil {
+		return err
+	}
+	command, err := contracts.Decode[contracts.Command](message.Payload, topic, time.Now().UTC())
+	if err != nil {
+		return err
+	}
+	if topic.DeviceID != deviceID {
+		return fmt.Errorf("command is for another device")
+	}
+	result := contracts.CommandResult{SchemaVersion: contracts.SchemaVersion, MessageID: "result-" + command.CommandID, DeviceID: deviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: command.CommandID, Status: "completed"}
+	payload, err := json.Marshal(result)
+	if err != nil {
+		return fmt.Errorf("encode 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, payload)
+}
+
+ +

🏗️ Build and run the validation boundary

+

After saving the contract files and updating both process entrypoints, run these from the repository root:

+
go test ./internal/contracts
+go build -o /tmp/mqtt5-lab-backend ./cmd/backend
+go build -o /tmp/mqtt5-lab-simulator ./cmd/simulator
+docker compose -p mqtt5-learning up -d broker
+docker compose -p mqtt5-learning ps
+

The contract tests exercise the topic and payload cases in the test file. Building each process checks that the typed contract is wired through the real programs. The broker command starts only the isolated learning service.

+

Run the simulator and backend in separate terminals:

+
/tmp/mqtt5-lab-simulator -username simulator -client-id lab-simulator-device-001
+
/tmp/mqtt5-lab-backend -username backend -client-id lab-backend
+

Expect the backend to log validated heartbeat identifiers and the simulator to remain connected. Then stop both clients with Ctrl+C and stop the broker with docker compose -p mqtt5-learning stop broker. This smoke run checks valid traffic; the unit tests cover malformed, oversized, and identity-mismatch inputs without relying on broker delivery. Ask me a follow-up if the result differs from your prediction.

+ +

✅ Takeaway

Route identifies a topic scope; contract validation checks a payload claim; broker ACLs decide who may publish. Pass only a validated typed value to application work.

Ask me a follow-up if any of those boundaries still blur together.

+ +
+ + diff --git a/teaching/lessons/0005a-backend-message-processing.html b/teaching/lessons/0005a-backend-message-processing.html new file mode 100644 index 0000000..20c1cb9 --- /dev/null +++ b/teaching/lessons/0005a-backend-message-processing.html @@ -0,0 +1,420 @@ + + + + + + Lesson 5A — Add the Backend Message-Processing Step + + + + +
+

Go MQTT Vending Backend · Lesson 5A · 15 minutes

+

Add the backend message-processing step

+

Section 5 validates incoming bytes. In 5A, add the application code that turns a valid heartbeat into a small, queryable backend state.

+
🎯 One skill: apply a validated message🧠 Separate device time from receive time🧪 In-memory lab
+ +
+

❓ Start with the repository as it is

+

The current cmd/backend/main.go receives mqtt.Message values from internal/mqtt and logs their topic and payload. The current codebase does not yet contain internal/deviceview, Store, Service, or the methods in this lesson.

+

Section 5A in teaching/course-source/05a-backend-message-processing.md is an implementation assignment: create those files and replace the raw-message log path with contract validation and application behavior. Every code block below is a complete file, including imports and wiring. The new code is the lesson's target, not code that already exists.

+
+ +

🗺️ First, see what the files do

+
+
+

✅ Existing files used by this lesson

+
    +
  • internal/contracts/topic.go defines TopicName, TopicFilter, Kind, concrete-topic builders, exact and fleet-wide subscription builders, and ParseTopic. Concrete publish names and subscription filters use distinct types.
  • +
  • internal/contracts/message.go defines the payload contracts and their topic/body validation methods.
  • +
  • internal/contracts/decode.go decodes and validates a payload against that parsed topic.
  • +
  • internal/mqtt/client.go defines mqtt.Message and copies received topic and payload into the inbox.
  • +
  • cmd/backend/main.go owns startup and the receive loop; today it logs received payloads.
  • +
+
+
+

🛠️ Files this lesson adds or changes

+
    +
  • Create internal/deviceview/store.go for an in-memory latest-heartbeat view.
  • +
  • Create internal/deviceview/service.go to validate messages and apply their current use cases.
  • +
  • Edit cmd/backend/main.go to create the service and delegate inbox messages to it.
  • +
+

Store, Service, RecordHeartbeat, and the other device-view methods are new lesson code. They are not present in the checkout before you implement this section.

+
+
+
+
Existing receive loop
topic + bytes
→ +
Existing contracts
parse + validate
→ +
New service
choose application behavior
→ +
New store
latest heartbeat view
+
+

Validation is a trust boundary, not a business action. The service applies the meaning of a valid message. The store owns the resulting view. Keeping these roles separate lets Section 6 move service work to workers without mixing application state with Paho callback code.

+ +

🗃️ Then, create `internal/deviceview/store.go`

+

File overview: this file owns one latest heartbeat snapshot per device. It keeps the map in process memory, so a backend restart clears the view.

+

`NewStore` summary: initializes the empty map. A query before the first heartbeat can report “not seen yet” rather than inventing an offline record.

+

`RecordHeartbeat` summary: parses device-reported time, copies optional temperature, and replaces the snapshot only when the report is newer.

+

`GetStatus` summary: returns the snapshot, plus whether the backend received it within the requested freshness window.

+

🧩 Complete file

+
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
+}
+
+
+

🔎 Details: how the store methods work

+
    +
  • NewStore: the map starts empty; the second return from GetStatus is false until a heartbeat is recorded.
  • +
  • RecordHeartbeat: contract decoding already checks OccurredAt, but the store parses it defensively too. ReportedAt orders observations; backend-supplied ReceivedAt measures freshness.
  • +
  • Optional temperature: the store keeps temperature as a value and uses TemperatureSet to distinguish a missing reading from a reported zero. The map snapshot copy is therefore independent without pointer-copy logic.
  • +
  • Locking: use an exclusive lock while comparing and replacing the map entry. Section 6 will allow multiple workers to call the service. A duplicate or older timestamp returns false, nil and leaves the newer view unchanged.
  • +
  • GetStatus: the read lock protects the map lookup. Freshness requires a positive window, a receive time not in the future, and an age no greater than that window.
  • +
+

Ordering is not durable deduplication. Equal timestamps count as not newer, and process restart removes all snapshots.

+
+ +

🧭 Next, create `internal/deviceview/service.go`

+

File overview: this file receives the existing transport-neutral mqtt.Message, validates it with contracts, applies the behavior assigned to its message kind, and logs a safe outcome. It does not own a Paho client or publish MQTT messages.

+

`NewService` summary: checks that the store, logger, and positive freshness window are configured before message handling begins.

+

`HandleMessage` summary: checks cancellation, parses the route, captures one backend receive time, then validates and handles the message according to its kind.

+

`Status` summary: asks the store for the latest view using the service clock and configured freshness window.

+

🧩 Complete file

+
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)
+}
+
+
+

🔎 Details: how the service methods work

+
    +
  • NewService: rejects missing dependencies and invalid policy at startup, not later as a nil-pointer failure. Its freshness window is application policy, separate from MQTT Keep Alive and Session Expiry.
  • +
  • HandleMessage: returns immediately for cancelled work or an invalid topic. It captures backend receive time once and reuses it in validation, storage, and logs.
  • +
  • Telemetry branch: decodes a heartbeat, asks the store to advance its snapshot, and logs either “recorded” or “older or duplicate ignored.” Invalid input never reaches the store.
  • +
  • Sales and response branches: decode and log identifiers only. Sales are not persisted here, and command responses are not correlated here. Those later outcomes must not be implied by a successful decode.
  • +
  • Status: returns backend state without querying the device. A fresh heartbeat means recent valid application traffic, not a live broker connection or healthy vending hardware.
  • +
+
+ +

🔌 Finally, edit `cmd/backend/main.go`

+

File overview: this existing file loads configuration, starts the MQTT client, owns the inbox receive loop, and periodically publishes the lab's ping command. The edited version keeps those responsibilities and delegates inbound messages to the new service instead of printing raw payloads.

+

run summary: constructs one store and service before MQTT startup, then passes each received message to handleMessage.

+

handleMessage summary: keeps the select loop focused on receiving events by delegating application behavior to Service.

+

🧩 Complete edited file

+
package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"example.com/mqtt5-learning/internal/config"
+	"example.com/mqtt5-learning/internal/deviceview"
+	"example.com/mqtt5-learning/internal/mqtt"
+)
+
+func main() {
+	if err := run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func run() error {
+	cfg, err := config.Load(os.Args[1:])
+	if err != nil {
+		return fmt.Errorf("failed to load config: %w", err)
+	}
+
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+
+	// Keep one shared in-memory view for the lifetime of this backend process.
+	store := deviceview.NewStore()
+	deviceView, err := deviceview.NewService(store, slog.Default(), 90*time.Second)
+	if err != nil {
+		return fmt.Errorf("create device-view service: %w", err)
+	}
+
+	telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry)
+	if err != nil { return err }
+	responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses)
+	if err != nil { return err }
+	commandTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindCommands)
+	if err != nil { return err }
+	inbox := make(chan mqtt.Message, 16)
+
+	manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{telemetryFilter, responsesFilter}, inbox)
+	if err != nil {
+		return fmt.Errorf("failed to start MQTT client: %w", err)
+	}
+	defer mqtt.Stop(manager)
+
+	ticker := time.NewTicker(10 * time.Second)
+	defer ticker.Stop()
+
+	runID := time.Now().UnixNano()
+	sequence := 0
+
+	for {
+		select {
+		case <-ctx.Done():
+			log.Print("shutting down")
+			return nil
+		case message := <-inbox:
+			if err := handleMessage(ctx, deviceView, message); err != nil {
+				slog.Error("message handling failed", "topic", message.Topic, "error", err)
+			}
+		case <-ticker.C:
+			sequence++
+			id := fmt.Sprintf("ping-%d-%d", runID, sequence)
+			payload, err := json.Marshal(contracts.Command{
+				SchemaVersion: contracts.SchemaVersion,
+				MessageID: id,
+				DeviceID: cfg.MQTT.DeviceID,
+				OccurredAt: time.Now().UTC().Format(time.RFC3339),
+				CommandID: id,
+				Command: "ping",
+			})
+			if err != nil {
+				return fmt.Errorf("failed to marshal payload: %w", err)
+			}
+			if err := mqtt.Publish(ctx, manager, commandTopic, payload); err != nil {
+				log.Printf("command publication failed or outcome uncertain: %v", err)
+			}
+		}
+	}
+}
+
+func handleMessage(ctx context.Context, deviceView *deviceview.Service, message mqtt.Message) error {
+	return deviceView.HandleMessage(ctx, message)
+}
+
+
+

🔎 Details: why the integration is placed here

+
    +
  • Construct the store once before the loop. Creating it per message would discard prior snapshots.
  • +
  • The 90-second freshness window is the course example policy; it should be chosen relative to the heartbeat interval and expected network delay.
  • +
  • The service handles inbound telemetry and response messages. The existing ticker and outbound ping remain intact.
  • +
  • Errors are logged with the topic and error, while the raw payload is no longer printed. Invalid or mismatched input therefore does not leak into a general log line.
  • +
  • The MQTT callback copies into the bounded inbox and returns before this receive loop applies the use case. Under automatic acknowledgement, a queue drop or later application error does not reverse an acknowledgement already sent. Section 6 studies that loss boundary and adds explicit overload handling.
  • +
+
+ +

🧪 Check your model

+
+
1. What is the status of `internal/deviceview`? + + + +
+
+
+
2. Which timestamp measures heartbeat freshness? + + + +
+
+
+
3. What does `HeartbeatFresh` mean? + + + +
+
+ +

🛠️ Your tiny practice

+
+

Predict the final store contents for these arrivals:

+
  1. The device reports 12:00:05; the backend receives it at 12:00:06.
  2. The device reports 12:00:02; the backend receives it at 12:00:09.
+

Which snapshot remains? At 12:00:09, is that snapshot fresh under a 90-second window? Explain why the later arrival does not move device-observation order backward.

+
+ +

🏗️ Build and run the vertical slice

+

After saving the new internal/deviceview files and updating cmd/backend/main.go, compile the real programs and start the isolated broker. Run these from the repository root:

+
go test ./...
+go build -o /tmp/mqtt5-lab-backend ./cmd/backend
+go build -o /tmp/mqtt5-lab-simulator ./cmd/simulator
+docker compose -p mqtt5-learning up -d broker
+docker compose -p mqtt5-learning ps
+

go test ./... runs the tests that exist; it does not prove every device-view branch is covered. The two builds catch compile and link errors in the actual processes. Check that the project-named broker is running before connecting clients.

+

Start each binary in its own terminal:

+
/tmp/mqtt5-lab-simulator -username simulator -client-id lab-simulator-device-001
+
/tmp/mqtt5-lab-backend -username backend -client-id lab-backend
+

Within a few seconds, expect backend log entries for recorded heartbeats with device and message IDs. The backend also sends a ping every ten seconds; the simulator replies, and the backend validates that response. This confirms a real MQTT message reached the application service. It does not establish command correlation, which Lesson 7 adds.

+

Stop both clients with Ctrl+C, then run docker compose -p mqtt5-learning stop broker. The device view is in memory and disappears when the backend exits. The lesson's temporary deviceView.Status diagnostic can show the snapshot and freshness flag; no HTTP status endpoint exists yet. Ask me a follow-up if a build error or log result is unclear.

+ +

✅ Takeaway

The existing code receives MQTT messages; the new service validates and applies them; the new store holds the latest heartbeat view. The implementation is complete above, but its state is RAM-only and a fresh heartbeat does not prove device health.

Ask me a follow-up if you want to trace one message through the code line by line.

+ + +
+ + diff --git a/teaching/lessons/0006-bounded-message-work.html b/teaching/lessons/0006-bounded-message-work.html new file mode 100644 index 0000000..be48522 --- /dev/null +++ b/teaching/lessons/0006-bounded-message-work.html @@ -0,0 +1,540 @@ + + + + + Lesson 6 — Bound Message Work + + +
+

Go MQTT Vending Backend · Lesson 6 · 8 minutes

+

Bound message work; make overload visible

+

Move application work out of the MQTT receive callback without creating an unlimited backlog or hiding queue rejection.

+
🎯 One skill: reason about admission🧠 Predict overload behavior🧪 In-memory queue
+ +

❓ Start with the story

A burst of vending heartbeats arrives while workers are slow. The callback returns, but the telemetry queue is full. What happened to the packet, and what can the backend truthfully claim?

Pause before reading: separate MQTT delivery acknowledgement from the backend's queue decision.

+

📍 Files to implement in the codebase

internal/dispatch/pool.go is already present. Read it first: this lesson teaches its admission, worker, and shutdown behavior, then aligns the routing, pool coordination, and both process entrypoints with the listings below.

PathActionWhat you will learn or change
internal/dispatch/pool.goRead and understand existing fileFixed workers, bounded queue, immediate admission outcomes, counters, bounded drain
internal/dispatch/receiver.goReview and alignRoute by parsed topic kind and copy payload ownership before enqueue
internal/dispatch/shutdown.goReview and alignStop every pool before waiting under one shared deadline
cmd/backend/main.goReplace with complete target file belowWire Section 5A service to separate bounded pools and preserve MQTT during drain
cmd/simulator/main.goReplace with complete target file belowHandle device commands through bounded work and drain before disconnect

The routing and shutdown files are already present in this checkout. Compare them with the complete listings below and preserve the same admission and error semantics.

+

📄 File overview: `pool.go` controls finite work

+

internal/dispatch/pool.go owns a fixed worker set and a bounded jobs channel. It limits simultaneous work and waiting work. It does not make queued messages durable, and it cannot ask MQTT to reverse an acknowledgement already sent.

+
MQTT callback
copy + submit
→
Finite queue
admit or reject
→
Fixed workers
run service
+ +
+

📥 `Submit` — short summary

+

Returns immediately with an explicit outcome: accepted, full, or stopping.

+
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
+    }
+}
+

This is the focused Submit method excerpt from Pool. The complete file defines the pool fields, Outcome values, worker loop, and shutdown behavior.

+

🧩 Details: why each branch exists

+
  • Reject nil or already-cancelled work before touching the queue.
  • The mutex makes the accepting check and channel send atomic with shutdown closing the channel.
  • The default branch means “no slot now”: this method never waits for capacity.
  • Accepted means queued in memory, not completed or saved to disk.
  • Full is an application overload decision. With automatic acknowledgement, returning it does not undo PUBACK or request redelivery.
+
+ +

⚖️ Choose a policy per traffic class

+

📈 Telemetry

If the business allows it, drop a stale heartbeat and count the rejection. Freshness will age out. Make the loss visible.

🧾 Sales and commands

Silently dropping a report or command is usually unacceptable. Return an explicit failure and add durable admission before claiming crash-safe processing.

+

Separate pools can prevent telemetry from consuming every waiting slot reserved for important work. That is capacity isolation, not durability. Shutdown order matters too: stop producers, stop admission, drain workers to a deadline while needed transport remains alive, then disconnect.

+

🛑 What does ctx.Err() contain during shutdown?

The ctx passed to Pool.Shutdown is the drain context. If its deadline expires before workers finish, ctx.Err() returns context.DeadlineExceeded. If something cancels it earlier, it returns context.Canceled. If workers finish first, Pool.Shutdown returns nil instead. On timeout, the pool uses that context error as its shutdown result and cancels the worker job context. Cancellation is cooperative, so a handler must check its context or use context-aware I/O to stop promptly.

+ +

🧭 File and function map

+

File overview: Section 6 adds three pieces: pool.go owns bounded execution, receiver.go routes each message to the right pool, and shutdown.go closes all pools before waiting for their workers. The backend entrypoint below wires those pieces to the Section 5A service and existing MQTT inbox.

+
+

📄 internal/dispatch/pool.go

+

New summary: starts a fixed worker count and a queue with a fixed capacity.

+

Submit summary: reports Accepted, Full, or Stopping without waiting for queue room.

+

StopAdmission / Shutdown summary: close the submission path, wait for workers up to a deadline, then cancel remaining work.

+

Stats / worker summary: expose admission and completion counts; workers run jobs and count success or failure.

+

🔎 Details

The mutex protects both the accepting flag and channel close, preventing a send-on-closed-channel race. The nonblocking select makes overload immediate. Closing the jobs channel lets workers finish buffered jobs; the shared deadline keeps draining bounded. If it expires, cancellation is cooperative: a job that ignores its context can keep running. Counters describe process-local events, not durable business records.

+
+

📄 internal/dispatch/receiver.go

+

Receiver.Submit summary: parses the topic, selects the telemetry or important pool, copies payload bytes, and submits a closure that calls the application processor.

+

🔎 Details

Routing on a parsed Kind keeps traffic policy out of the MQTT callback. Commands, sales, and command responses use the important pool; only telemetry uses the telemetry pool. Copying the payload transfers ownership: the worker no longer depends on Paho or a reused inbox buffer. The job closure captures that owned copy and runs later with the worker's context.

+
+

📄 internal/dispatch/shutdown.go

+

Shutdown summary: stops submissions to every pool first, then drains them using one shared deadline.

+

🔎 Details

Stopping all pools before waiting prevents one pool from accepting new work while the other drains. Reusing one context bounds the total shutdown time instead of allowing a fresh timeout for each pool. errors.Join preserves timeout failures from multiple pools. Keep MQTT alive during this interval if jobs need to publish their result; disconnect only after the drain attempt.

+
+
+

🔌 Integration overview: edit cmd/backend/main.go

+

run summary: builds the service and pools once, starts MQTT, sends each inbox message through Receiver, and drains workers when the signal context ends.

+

Details: the signal context stops the main loop, while a separate transport context keeps MQTT available during the bounded drain. The signal case itself only logs and returns; as run returns, deferred cleanup executes in reverse registration order. The dispatcher drain defer was registered after mqtt.Stop, so it runs first. Pool jobs use an independent parent context so the signal does not cancel them before draining. Once drain completes or times out, mqtt.Stop disconnects the client. The inbox and callback remain bounded; if more messages arrive during shutdown they can still be dropped under automatic acknowledgement, which this lesson reports rather than hiding.

+
🧩 Complete Section 6 backend wiring: cmd/backend/main.go

This is the full target entrypoint layered on the Section 5A service. The three dispatcher files follow it below.

package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"example.com/mqtt5-learning/internal/config"
+	"example.com/mqtt5-learning/internal/contracts"
+	"example.com/mqtt5-learning/internal/deviceview"
+	"example.com/mqtt5-learning/internal/dispatch"
+	"example.com/mqtt5-learning/internal/mqtt"
+)
+
+func main() {
+	if err := run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func run() error {
+	cfg, err := config.Load(os.Args[1:])
+	if err != nil {
+		return fmt.Errorf("failed to load config: %w", err)
+	}
+
+	lifecycleCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	transportCtx, stopTransport := context.WithCancel(context.Background())
+	defer stopTransport()
+
+	store := deviceview.NewStore()
+	service, err := deviceview.NewService(store, slog.Default(), 90*time.Second)
+	if err != nil {
+		return fmt.Errorf("create device-view service: %w", err)
+	}
+
+	telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry)
+	if err != nil { return err }
+	responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses)
+	if err != nil { return err }
+	commandTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindCommands)
+	if err != nil { return err }
+	inbox := make(chan mqtt.Message, 16)
+	manager, err := mqtt.Start(transportCtx, cfg, []contracts.TopicFilter{telemetryFilter, responsesFilter}, inbox)
+	if err != nil {
+		return fmt.Errorf("failed to start MQTT client: %w", err)
+	}
+	defer mqtt.Stop(manager)
+
+	telemetryPool := dispatch.New(context.Background(), 2, 16)
+	importantPool := dispatch.New(context.Background(), 2, 32)
+	receiver := &dispatch.Receiver{
+		Telemetry: telemetryPool,
+		Important: importantPool,
+		Process:   service.HandleMessage,
+	}
+	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())
+	}()
+
+	ticker := time.NewTicker(10 * time.Second)
+	defer ticker.Stop()
+	runID := time.Now().UnixNano()
+	sequence := 0
+
+	for {
+		select {
+		case <-lifecycleCtx.Done():
+			log.Print("shutting down; draining accepted work before MQTT disconnect")
+			return nil
+		case message := <-inbox:
+			outcome, submitErr := receiver.Submit(lifecycleCtx, message)
+			if submitErr != nil || outcome != dispatch.Accepted {
+				slog.Warn("message admission rejected", "topic", message.Topic, "outcome", outcome, "error", submitErr)
+			}
+		case <-ticker.C:
+			sequence++
+			id := fmt.Sprintf("ping-%d-%d", runID, sequence)
+			payload, marshalErr := json.Marshal(contracts.Command{
+				SchemaVersion: contracts.SchemaVersion,
+				MessageID: id,
+				DeviceID: cfg.MQTT.DeviceID,
+				OccurredAt: time.Now().UTC().Format(time.RFC3339),
+				CommandID: id,
+				Command: "ping",
+			})
+			if marshalErr != nil {
+				return fmt.Errorf("failed to marshal payload: %w", marshalErr)
+			}
+			if publishErr := mqtt.Publish(lifecycleCtx, manager, commandTopic, payload); publishErr != nil {
+				log.Printf("command publication failed or outcome uncertain: %v", publishErr)
+			}
+		}
+	}
+}
+
+

🤖 Simulator integration overview: edit cmd/simulator/main.go

+

run summary: starts the device client, publishes heartbeats, and submits received commands to bounded workers instead of handling them inline in the main loop.

+

respond summary: rejects retained or misrouted commands, validates the contract, and publishes the pong using the worker context.

+

Details: the simulator attempts to drain accepted command jobs before closing MQTT so a worker can still publish its response. The drain has a five-second limit; if it expires, remaining in-memory work may not complete. A full important queue is logged as an application rejection; automatic acknowledgement may already have completed the subscriber delivery, so this RAM-only example cannot promise redelivery.

+
🧩 Complete Section 6 simulator wiring: cmd/simulator/main.go

This complete target file carries the Section 5 contract-aware simulator forward and moves inbound command work to the dispatcher.

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"
+	"example.com/mqtt5-learning/internal/mqtt"
+	"github.com/eclipse/paho.golang/autopaho"
+)
+
+func main() {
+	if err := run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func run() error {
+	cfg, err := config.Load(os.Args[1:])
+	if err != nil {
+		return fmt.Errorf("load config: %w", err)
+	}
+	lifecycleCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	transportCtx, stopTransport := context.WithCancel(context.Background())
+	defer stopTransport()
+
+	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 fmt.Errorf("start MQTT: %w", err)
+	}
+	defer mqtt.Stop(manager)
+
+	runID := time.Now().UnixNano()
+	telemetryPool := dispatch.New(context.Background(), 2, 16)
+	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())
+	}()
+
+	ticker := time.NewTicker(3 * time.Second)
+	defer ticker.Stop()
+	sequence := uint64(0)
+	for {
+		select {
+		case <-lifecycleCtx.Done():
+			log.Print("shutting down; draining accepted command work before MQTT disconnect")
+			return nil
+		case <-ticker.C:
+			sequence++
+			heartbeat := contracts.Heartbeat{
+				SchemaVersion: contracts.SchemaVersion,
+				MessageID:     fmt.Sprintf("heartbeat-%d-%d", time.Now().Unix(), sequence),
+				DeviceID:      cfg.MQTT.DeviceID,
+				OccurredAt:    time.Now().UTC().Format(time.RFC3339),
+			}
+			payload, marshalErr := json.Marshal(heartbeat)
+			if marshalErr != nil {
+				return fmt.Errorf("encode heartbeat: %w", marshalErr)
+			}
+			if publishErr := mqtt.Publish(lifecycleCtx, manager, telemetryTopic, payload); publishErr != nil {
+				log.Printf("heartbeat publish: %v", publishErr)
+			}
+		case message := <-inbox:
+			outcome, submitErr := receiver.Submit(lifecycleCtx, 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
+	}
+	result := contracts.CommandResult{
+		SchemaVersion: contracts.SchemaVersion,
+		MessageID:     resultMessageID,
+		DeviceID:      deviceID,
+		OccurredAt:    time.Now().UTC().Format(time.RFC3339),
+		CommandID:     command.CommandID,
+		Status:        "completed",
+	}
+	payload, err := json.Marshal(result)
+	if err != nil {
+		return fmt.Errorf("encode 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, payload)
+}
+
+ +

🧪 Check your model

+
1. The bounded queue returns `Full`. What does that change?
+
2. What does `Accepted` mean for a queue job?
+
3. What should shutdown do before MQTT disconnect?
+ +

🛠️ Your tiny practice

When capacity is full, write one sentence and one metric for each class: heartbeat, sales report, command result. Then state the exact loss window created by automatic acknowledgement plus an in-memory queue. Compare with Section 6's dispatcher and shutdown code ↗.

+ +
📦 Section 6 complete dispatcher files

Complete source files for the added dispatcher package. The complete backend integration appears immediately above.

📄 internal/dispatch/pool.go

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)
+		}
+	}
+}
+
+

📄 internal/dispatch/receiver.go

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) })
+}
+
+

📄 internal/dispatch/shutdown.go

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
+}
+
+ +

🏗️ Build and smoke-run the updated processes

+

After saving all three internal/dispatch files and wiring both entrypoints, run these commands from the repository root:

+
go test ./...
+go test -race ./...
+go build -o /tmp/mqtt5-lab-backend ./cmd/backend
+go build -o /tmp/mqtt5-lab-simulator ./cmd/simulator
+

The race detector can report races only on paths the tests execute; it does not prove every worker schedule safe. The builds confirm both programs use the updated wiring.

+

Start only the named lab broker, then run the two binaries in separate terminals:

+
docker compose -p mqtt5-learning up -d broker
+docker compose -p mqtt5-learning ps
+
/tmp/mqtt5-lab-simulator -username simulator -client-id lab-simulator-device-001
+
/tmp/mqtt5-lab-backend -username backend -client-id lab-backend
+

Confirm heartbeats and ping responses still complete while passing through worker queues. On Ctrl+C, observe the shutdown summary and compare accepted, rejected, completed, failed, and drain-time values if your wiring logs them. This ordinary traffic is a smoke run, not an overload test; use a finite burst larger than active workers plus queue capacity to observe Full outcomes. Stop the clients, then stop the lab broker with docker compose -p mqtt5-learning stop broker.

+

Ask me a follow-up if the counters or shutdown behavior differ from your prediction.

+ +

✅ Takeaway

A bounded queue protects process memory; an explicit overload policy protects business meaning. Neither makes work durable.

Tell me which message class you would admit first and why.

+ +
diff --git a/teaching/lessons/0007-command-state.html b/teaching/lessons/0007-command-state.html new file mode 100644 index 0000000..0b0ba80 --- /dev/null +++ b/teaching/lessons/0007-command-state.html @@ -0,0 +1,745 @@ + + + + + Lesson 7 — Match Command Results to the Right Request + + +
+

Go MQTT Vending Backend · Lesson 7 · 8 minutes

+

Match command results to the right request

+

Keep broker acknowledgement, device result, and timeout uncertainty separate while several vending commands are in flight.

+
🎯 One skill: correlate safely🧠 Predict a fast response🧪 Simulated device
+ +

❓ Start with the story

The backend publishes command C-42. PUBACK arrives, but no device result arrives before the deadline. Can the backend say the command failed or that the machine did nothing?

State what is known, what is unknown, and which ID can tie a later result to this command.

+

📍 Checkout status

The current codebase does not yet contain internal/commands or internal/simulator, and its mqtt.Message has no Response Topic or Correlation Data fields. Section 7 creates those files and extends the MQTT message copy; complete source files are included below.

Durability boundary: command records and simulator deduplication are process-local. Restarting either process loses its in-memory state. Sections 13–14 add durable records and recoverable publication intent.

+

🗺️ File overview: command state outlives a packet

+

internal/commands/store.go owns command records and state transitions. service.go registers state before publishing and records timeouts. The MQTT adapter carries Response Topic and Correlation Data; the simulator checks its reply destination and suppresses repeated effects by command ID.

+
Create stable ID
pending record
→
Publish MQTT
PUBACK evidence
→
Match result
device evidence
→
No result by deadline
unknown
+ +

🚀 `CreateAndPublish` — short summary

Creates correlation bytes, stores pending state, publishes with a deadline, then records broker acknowledgement separately.

🧩 Details: insert before publish

record := Record{
+    CommandID: commandID,
+    DeviceID: deviceID,
+    CorrelationData: correlation,
+    Status: Pending,
+    Deadline: now.Add(timeout),
+}
+if err := store.Insert(record); err != nil {
+    return Record{}, err
+}
+
+// Only after pending state exists:
+replyTopic, err := contracts.DeviceTopic(deviceID, contracts.KindResponses)
+if err != nil { return Record{}, err }
+reason, err := publisher.PublishCommand(ctx, deviceID, command, replyTopic, correlation)
+if err != nil {
+    return store.MarkUnknownIfPending(deviceID, commandID, "publish outcome uncertain")
+}
+return store.MarkBrokerAck(deviceID, commandID, reason)

A device can reply before the publish call returns, so pending state must exist first. If the publish call times out, the backend may not know whether the broker accepted it; mark the outcome unknown rather than asserting no effect. A late response still needs an explicit policy.

+ +

🪪 Three identifiers have different jobs

+
  • MQTT packet ID: protocol bookkeeping for an exchange; it can be reused.
  • Correlation Data: opaque bytes to match one response to one pending request; it is not authorization.
  • Application command ID: stable business identity across retries; it supports device-side deduplication and status lookup.

The responder must also check that the Response Topic is authorized for that device. Matching identity is not proof of identity; credentials and broker ACLs remain separate controls.

+

🧭 Keep the state meanings honest

+
Evidence or eventSafe interpretation
Created and storedPending publish attempt
Successful PUBACKBroker acknowledged this publish hop
Validated device resultApply accepted, completed, or rejected evidence
Deadline with no resultUnknown outcome; the effect may or may not have happened
+

Conceptually, the lifecycle is created → pending delivery → accepted → completed/rejected; a deadline moves unresolved work to unknown. A valid late result may resolve unknown here, while completed and rejected remain terminal. The reference store inserts directly as Pending before PUBLISH, so creation is not a separate queryable status. BrokerAcked is orthogonal to application status.

+ +

🧭 File and function map

+

File overview: commands/store.go owns in-memory records and transition checks; commands/service.go coordinates state and publishing; mqtt/command_publisher.go translates a command into an MQTT 5 packet; commands/receive.go validates incoming results. The simulator files validate, deduplicate, and answer commands. The modified mqtt/client.go copies MQTT 5 response properties into the app-owned message.

+
+

📦 internal/commands/store.go

+

Function summaries: NewStore creates the table; Insert, Get, Update, and List manage records; MarkBrokerAck records transport evidence; MarkUnknownIfPending records uncertainty; ApplyResult matches device evidence.

+

🔎 Details

Keys include both device and command ID so equal IDs from different devices do not collide. The lock protects concurrent HTTP, MQTT, and timeout activity. Correlation bytes are copied at storage boundaries. ApplyResult checks device scope and correlation before changing state, and preserves terminal Completed/Rejected results against duplicates. Broker acknowledgement remains an independent field, not a command status.

+
+

🧠 internal/commands/service.go

+

Function summaries: NewService wires the store, publisher, clock, and response timeout; CreateAndPublish stores pending state before MQTT publish; HandleResult delegates validated result matching; Expire marks overdue work unknown; Get reads current state.

+

🔎 Details

The pending insert must happen before publishing because a fast simulator can answer before PublishCommand returns. A publish timeout can mean “ack not observed,” not “broker definitely did not accept it,” so the service marks the outcome unknown. Expire uses the same uncertainty meaning and never reports that the physical action did not occur. In this teaching version, the clock and records are process-local.

+
+

📡 MQTT adapter and result receiver

+

PublishCommand summary: validates the device and reply destination, encodes the contract, publishes QoS 1 with Response Topic and Correlation Data, and returns the broker reason code.

+

HandleMessage summary: parses the response route, decodes a CommandResult, and passes copied correlation bytes to the service.

+

mqtt.Start summary: the Section 7 target copies Response Topic and Correlation Data inside the callback, while the packet properties are still available.

+

🔎 Details

The adapter verifies that the reply topic belongs to the target device before publishing. A response is not accepted just because it arrived on a response-shaped topic: the contract must validate and the store must match its device and correlation to a pending record. Correlation matches a request; broker ACLs and device credentials authorize who can publish.

+
+

🤖 Simulator files

+

AuthorizedResponseTopic summary: permits only that device's response topic. Deduplicator.Apply summary: runs an effect once per device/command ID while its bounded cache retains the result. Respond summary: validates route, payload, retained flag, and response destination, then publishes the result with the original correlation bytes. run summary: owns MQTT startup, heartbeat publication, command receive, and cancellation.

+

🔎 Details

The cache fingerprints command content. Reusing an ID with changed content is an error; repeating identical content replays the saved result without repeating the effect. Because the cache is in memory and bounded, this is a simulator guarantee only while the entry remains cached and the process stays alive—not production durable exactly-once execution.

+
+
+ +

🧪 Check your model

+
1. What can the backend conclude from PUBACK?
+
2. What should timeout mean without a response?
+
3. What does Correlation Data not provide?
+ +

🛠️ Your tiny practice

Trace this race: pending record inserted → PUBLISH begins → device responds → PUBACK returns. Which state fields change at each step? Then let a command time out, deliver a valid late result with matching device and correlation, and verify the reference policy resolves unknown without regressing completed or rejected. Finally send the same command ID twice and explain which component prevents the simulated effect from running twice.

Compare with Section 7's complete command store and simulator code ↗.

+ + +
📦 Section 7 complete command and simulator files

Complete source files for this lesson are included here. The code blocks earlier in the lesson focus on the behavior being taught; use these full files when implementing it.

📄 internal/commands/store.go

package commands
+
+import (
+	"bytes"
+	"fmt"
+	"sync"
+	"time"
+
+	"example.com/mqtt5-learning/internal/contracts"
+)
+
+type Status string
+
+// Status records application progress independently from BrokerAcked.
+const (
+	Pending   Status = "pending"
+	Accepted  Status = "accepted"
+	Completed Status = "completed"
+	Rejected  Status = "rejected"
+	Unknown   Status = "unknown"
+)
+
+type Record struct {
+	CommandID       string
+	DeviceID        string
+	CorrelationData []byte
+	CreatedAt       time.Time
+	Deadline        time.Time
+	Status          Status
+	BrokerAcked     bool
+	AckReasonCode   byte
+	Detail          string
+}
+
+type Store struct {
+	mu      sync.RWMutex
+	records map[string]Record
+}
+
+// NewStore creates the in-memory command record table.
+func NewStore() *Store { return &Store{records: make(map[string]Record)} }
+
+func key(deviceID, commandID string) string { return deviceID + "/" + commandID }
+
+func (s *Store) Insert(record Record) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	k := key(record.DeviceID, record.CommandID)
+	if _, exists := s.records[k]; exists {
+		return fmt.Errorf("command already exists")
+	}
+	record.CorrelationData = append([]byte(nil), record.CorrelationData...)
+	s.records[k] = record
+	return nil
+}
+
+// Get and Update copy correlation bytes so callers cannot mutate stored state.
+func (s *Store) Get(deviceID, commandID string) (Record, bool) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	record, ok := s.records[key(deviceID, commandID)]
+	record.CorrelationData = append([]byte(nil), record.CorrelationData...)
+	return record, ok
+}
+
+func (s *Store) Update(record Record) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	k := key(record.DeviceID, record.CommandID)
+	if _, exists := s.records[k]; !exists {
+		return fmt.Errorf("command not found")
+	}
+	record.CorrelationData = append([]byte(nil), record.CorrelationData...)
+	s.records[k] = record
+	return nil
+}
+
+func (s *Store) MarkBrokerAck(deviceID, commandID string, reason byte) (Record, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	k := key(deviceID, commandID)
+	record, ok := s.records[k]
+	if !ok {
+		return Record{}, fmt.Errorf("command not found")
+	}
+	record.BrokerAcked, record.AckReasonCode = true, reason
+	s.records[k] = record
+	return record, nil
+}
+
+// MarkUnknownIfPending preserves any application result that arrived first.
+func (s *Store) MarkUnknownIfPending(deviceID, commandID, detail string) (Record, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	k := key(deviceID, commandID)
+	record, ok := s.records[k]
+	if !ok {
+		return Record{}, fmt.Errorf("command not found")
+	}
+	if record.Status == Pending || record.Status == Accepted {
+		record.Status, record.Detail = Unknown, detail
+	}
+	s.records[k] = record
+	return record, nil
+}
+
+// ApplyResult checks both device scope and correlation before changing status.
+func (s *Store) ApplyResult(deviceID string, correlation []byte, result contracts.CommandResult) (Record, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	k := key(deviceID, result.CommandID)
+	record, ok := s.records[k]
+	if !ok {
+		return Record{}, fmt.Errorf("no pending command matches result")
+	}
+	if result.DeviceID != record.DeviceID || !bytes.Equal(correlation, record.CorrelationData) {
+		return Record{}, fmt.Errorf("result identity or correlation does not match pending command")
+	}
+	if record.Status == Completed || record.Status == Rejected {
+		return record, nil
+	}
+	switch result.Status {
+	case "accepted":
+		record.Status = Accepted
+	case "completed":
+		record.Status = Completed
+	case "rejected":
+		record.Status = Rejected
+	default:
+		return Record{}, fmt.Errorf("unsupported result status %q", result.Status)
+	}
+	record.Detail = result.Detail
+	s.records[k] = record
+	return record, nil
+}
+
+func (s *Store) List() []Record {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	result := make([]Record, 0, len(s.records))
+	for _, record := range s.records {
+		record.CorrelationData = append([]byte(nil), record.CorrelationData...)
+		result = append(result, record)
+	}
+	return result
+}
+
+

📄 internal/commands/service.go

package commands
+
+import (
+	"context"
+	"crypto/rand"
+	"fmt"
+	"time"
+
+	"example.com/mqtt5-learning/internal/contracts"
+)
+
+type Publisher interface {
+	PublishCommand(context.Context, string, contracts.Command, contracts.TopicName, []byte) (byte, error)
+}
+
+// Service coordinates command identity, storage, publication, and deadlines.
+type Service struct {
+	store     *Store
+	publisher Publisher
+	now       func() time.Time
+	timeout   time.Duration
+}
+
+func NewService(store *Store, publisher Publisher, timeout time.Duration) *Service {
+	return &Service{store: store, publisher: publisher, now: func() time.Time { return time.Now().UTC() }, timeout: timeout}
+}
+
+func (s *Service) CreateAndPublish(ctx context.Context, deviceID, commandID string) (Record, error) {
+	responseTopic, err := contracts.DeviceTopic(deviceID, contracts.KindResponses)
+	if err != nil { return Record{}, fmt.Errorf("build response topic: %w", err) }
+	// Store pending state first so a fast device response can match immediately.
+	now := s.now()
+	correlation := make([]byte, 16)
+	if _, err := rand.Read(correlation); err != nil {
+		return Record{}, fmt.Errorf("create correlation data: %w", err)
+	}
+	record := Record{CommandID: commandID, DeviceID: deviceID, CorrelationData: correlation, CreatedAt: now, Deadline: now.Add(s.timeout), Status: Pending}
+	if err := s.store.Insert(record); err != nil {
+		return Record{}, err
+	}
+	command := contracts.Command{SchemaVersion: contracts.SchemaVersion, MessageID: "message-" + commandID, DeviceID: deviceID, OccurredAt: now.Format(time.RFC3339), CommandID: commandID, Command: "ping"}
+	ctx, cancel := context.WithDeadline(ctx, record.Deadline)
+	defer cancel()
+	reason, err := s.publisher.PublishCommand(ctx, deviceID, command, responseTopic, append([]byte(nil), correlation...))
+	if err != nil {
+		updated, _ := s.store.MarkUnknownIfPending(deviceID, commandID, "publish outcome uncertain: "+err.Error())
+		return updated, err
+	}
+	updated, err := s.store.MarkBrokerAck(deviceID, commandID, reason)
+	return updated, err
+}
+
+func (s *Service) HandleResult(deviceID string, correlation []byte, result contracts.CommandResult) (Record, error) {
+	return s.store.ApplyResult(deviceID, correlation, result)
+}
+
+// Expire records uncertainty; it does not claim that the device did no work.
+func (s *Service) Expire(now time.Time) {
+	for _, record := range s.store.List() {
+		if (record.Status == Pending || record.Status == Accepted) && !now.Before(record.Deadline) {
+			_, _ = s.store.MarkUnknownIfPending(record.DeviceID, record.CommandID, "response deadline expired; execution outcome is unknown")
+		}
+	}
+}
+
+func (s *Service) Get(commandID string) (Record, bool) {
+	for _, record := range s.store.List() {
+		if record.CommandID == commandID {
+			return record, true
+		}
+	}
+	return Record{}, false
+}
+
+

📄 internal/mqtt/command_publisher.go

package mqtt
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"example.com/mqtt5-learning/internal/contracts"
+	"github.com/eclipse/paho.golang/autopaho"
+	"github.com/eclipse/paho.golang/paho"
+)
+
+type CommandPublisher struct {
+	Manager *autopaho.ConnectionManager
+}
+
+func (p CommandPublisher) PublishCommand(ctx context.Context, deviceID string, command contracts.Command, responseTopic contracts.TopicName, correlation []byte) (byte, error) {
+	// Authorize the reply destination before putting it in the MQTT packet.
+	authorized, err := contracts.DeviceTopic(deviceID, contracts.KindResponses)
+	if err != nil { return 0, fmt.Errorf("build response topic: %w", err) }
+	if responseTopic != authorized {
+		return 0, fmt.Errorf("unauthorized response topic")
+	}
+	if command.DeviceID != deviceID {
+		return 0, fmt.Errorf("command device does not match publish destination")
+	}
+	commandTopic, err := contracts.DeviceTopic(deviceID, contracts.KindCommands)
+	if err != nil { return 0, fmt.Errorf("build command topic: %w", err) }
+	payload, err := json.Marshal(command)
+	if err != nil {
+		return 0, fmt.Errorf("encode command: %w", err)
+	}
+	operation, cancel := context.WithTimeout(ctx, 3*time.Second)
+	defer cancel()
+	// Correlation Data is copied so the packet owns stable bytes for this publish.
+	ack, err := p.Manager.Publish(operation, &paho.Publish{
+		Topic:   commandTopic.String(),
+		QoS:     1,
+		Payload: payload,
+		Properties: &paho.PublishProperties{
+			ContentType:     "application/json",
+			ResponseTopic:   responseTopic.String(),
+			CorrelationData: append([]byte(nil), correlation...),
+		},
+	})
+	if err != nil {
+		return 0, fmt.Errorf("publish command: %w", err)
+	}
+	if ack == nil {
+		return 0, fmt.Errorf("missing QoS 1 PUBACK")
+	}
+	if ack.ReasonCode >= 0x80 {
+		return ack.ReasonCode, fmt.Errorf("broker rejected publish: reason 0x%02x", ack.ReasonCode)
+	}
+	return ack.ReasonCode, nil
+}
+
+

📄 internal/commands/receive.go

package commands
+
+import (
+	"fmt"
+	"time"
+
+	"example.com/mqtt5-learning/internal/contracts"
+	"example.com/mqtt5-learning/internal/mqtt"
+)
+
+func HandleMessage(service *Service, message mqtt.Message) error {
+	// Validate the route and result body before matching the pending request.
+	topic, err := contracts.ParseTopic(message.Topic)
+	if err != nil {
+		return err
+	}
+	if topic.Kind != contracts.KindResponses {
+		return fmt.Errorf("expected a response topic")
+	}
+	result, err := contracts.Decode[contracts.CommandResult](message.Payload, topic, time.Now().UTC())
+	if err != nil {
+		return err
+	}
+	_, err = service.HandleResult(topic.DeviceID, append([]byte(nil), message.CorrelationData...), result)
+	return err
+}
+
+

📄 internal/simulator/dedup.go

package simulator
+
+import (
+	"crypto/sha256"
+	"encoding/json"
+	"fmt"
+	"sync"
+
+	"example.com/mqtt5-learning/internal/contracts"
+)
+
+type cachedResult struct {
+	fingerprint [32]byte
+	result      contracts.CommandResult
+}
+
+type Deduplicator struct {
+	mu       sync.Mutex
+	capacity int
+	results  map[string]cachedResult
+}
+
+// NewDeduplicator bounds how many command results this simulator retains.
+func NewDeduplicator(capacity int) *Deduplicator {
+	if capacity < 1 {
+		capacity = 1
+	}
+	return &Deduplicator{capacity: capacity, results: make(map[string]cachedResult)}
+}
+
+func AuthorizedResponseTopic(deviceID, requested string) bool {
+	expected, err := contracts.DeviceTopic(deviceID, contracts.KindResponses)
+	return err == nil && requested == expected.String()
+}
+
+// Apply executes effect at most once for a device/command ID while this process lives.
+func (d *Deduplicator) Apply(command contracts.Command, effect func() (contracts.CommandResult, error)) (contracts.CommandResult, bool, error) {
+	if effect == nil {
+		return contracts.CommandResult{}, false, fmt.Errorf("effect is required")
+	}
+	encoded, err := json.Marshal(command)
+	if err != nil {
+		return contracts.CommandResult{}, false, fmt.Errorf("fingerprint command: %w", err)
+	}
+	fingerprint := sha256.Sum256(encoded)
+	key := command.DeviceID + "/" + command.CommandID
+	d.mu.Lock()
+	defer d.mu.Unlock()
+	if previous, ok := d.results[key]; ok {
+		if previous.fingerprint != fingerprint {
+			return contracts.CommandResult{}, false, fmt.Errorf("command ID was reused with different content")
+		}
+		return previous.result, true, nil
+	}
+	if len(d.results) >= d.capacity {
+		return contracts.CommandResult{}, false, fmt.Errorf("deduplication cache is full")
+	}
+	result, err := effect()
+	if err != nil {
+		return contracts.CommandResult{}, false, err
+	}
+	d.results[key] = cachedResult{fingerprint: fingerprint, result: result}
+	return result, false, nil
+}
+
+

📄 internal/simulator/respond.go

package simulator
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"example.com/mqtt5-learning/internal/contracts"
+	"example.com/mqtt5-learning/internal/mqtt"
+	"github.com/eclipse/paho.golang/autopaho"
+	"github.com/eclipse/paho.golang/paho"
+)
+
+func Respond(ctx context.Context, manager *autopaho.ConnectionManager, cache *Deduplicator, deviceID string, message mqtt.Message) error {
+	// Reject retained or misrouted requests before validating or applying an effect.
+	if message.Retain {
+		return fmt.Errorf("retained commands are 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
+	}
+	if !AuthorizedResponseTopic(deviceID, message.ResponseTopic) {
+		return fmt.Errorf("unauthorized response topic")
+	}
+	// Cache the simulated result by stable command ID to suppress repeat effects.
+	result, _, err := cache.Apply(command, func() (contracts.CommandResult, error) {
+		return contracts.CommandResult{SchemaVersion: contracts.SchemaVersion, MessageID: "result-" + command.CommandID, DeviceID: deviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: command.CommandID, Status: "completed"}, nil
+	})
+	if err != nil {
+		return err
+	}
+	payload, err := json.Marshal(result)
+	if err != nil {
+		return fmt.Errorf("encode result: %w", err)
+	}
+	operation, cancel := context.WithTimeout(ctx, 3*time.Second)
+	defer cancel()
+	// Reply only to the authorized destination and echo correlation bytes unchanged.
+	ack, err := manager.Publish(operation, &paho.Publish{Topic: message.ResponseTopic, QoS: 1, Payload: payload, Properties: &paho.PublishProperties{ContentType: "application/json", CorrelationData: append([]byte(nil), message.CorrelationData...)}})
+	if err != nil {
+		return fmt.Errorf("publish response: %w", err)
+	}
+	if ack == nil {
+		return fmt.Errorf("missing response PUBACK")
+	}
+	if ack.ReasonCode >= 0x80 {
+		return fmt.Errorf("response publish rejected: reason 0x%02x", ack.ReasonCode)
+	}
+	return nil
+}
+
+

📄 cmd/simulator/main.go

package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"example.com/mqtt5-learning/internal/config"
+	"example.com/mqtt5-learning/internal/contracts"
+	"example.com/mqtt5-learning/internal/mqtt"
+	"example.com/mqtt5-learning/internal/simulator"
+)
+
+func main() {
+	if err := run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func run() error {
+	cfg, err := config.Load(os.Args[1:])
+	if err != nil {
+		return fmt.Errorf("load config: %w", err)
+	}
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	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(ctx, cfg, []contracts.TopicFilter{commandFilter}, inbox)
+	if err != nil {
+		return fmt.Errorf("start MQTT: %w", err)
+	}
+	defer mqtt.Stop(manager)
+	// Keep recent command results so repeated delivery does not repeat the effect.
+	cache := simulator.NewDeduplicator(1024)
+	ticker := time.NewTicker(3 * time.Second)
+	defer ticker.Stop()
+	sequence := 0
+	for {
+		select {
+		case <-ctx.Done():
+			return nil
+		case <-ticker.C:
+			sequence++
+			heartbeat := contracts.Heartbeat{SchemaVersion: contracts.SchemaVersion, MessageID: fmt.Sprintf("heartbeat-%d-%d", time.Now().Unix(), sequence), DeviceID: cfg.MQTT.DeviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339)}
+			payload, err := json.Marshal(heartbeat)
+			if err != nil {
+				return fmt.Errorf("encode heartbeat: %w", err)
+			}
+			if err := mqtt.Publish(ctx, manager, telemetryTopic, payload); err != nil {
+				log.Printf("heartbeat publish: %v", err)
+			}
+		case message := <-inbox:
+			if err := simulator.Respond(ctx, manager, cache, cfg.MQTT.DeviceID, message); err != nil {
+				log.Printf("command rejected or response failed topic=%q reason=%v", message.Topic, err)
+			}
+		}
+	}
+}
+

📄 internal/mqtt/client.go — complete file with Section 7 message properties

This target version extends the existing MQTT transport message and copies MQTT 5 response properties inside the callback, before the received packet goes out of scope.

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
+	ResponseTopic   string
+	CorrelationData []byte
+}
+
+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}
+				if properties := packet.Properties; properties != nil {
+					message.ResponseTopic = properties.ResponseTopic
+					message.CorrelationData = append([]byte(nil), properties.CorrelationData...)
+				}
+				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)
+	}
+}
+
+
+ +

🏗️ Build and run the command-state stage

+

After saving the Section 7 files and updating internal/mqtt/client.go, check the repository and build both processes from its root:

+
go test ./...
+go test -race ./...
+go build -o /tmp/mqtt5-lab-backend ./cmd/backend
+go build -o /tmp/mqtt5-lab-simulator ./cmd/simulator
+

These commands run the tests that currently exist and check compilation. The race detector only covers schedules exercised by tests. Section 7 has no HTTP command entrypoint yet, so starting the processes verifies MQTT startup, subscriptions, and simulator heartbeats; it does not trigger a command/response round trip by itself.

+

For that smoke run, start the named broker and inspect its state:

+
docker compose -p mqtt5-learning up -d broker
+docker compose -p mqtt5-learning ps
+

Then run these in separate terminals:

+
/tmp/mqtt5-lab-simulator -username simulator -client-id lab-simulator-device-001
+
/tmp/mqtt5-lab-backend -username backend -client-id lab-backend
+

Expect successful connection/subscription logs and simulator heartbeats. Stop both with Ctrl+C, then stop the broker using docker compose -p mqtt5-learning stop broker. Lesson 8 adds the HTTP endpoint that can create a ping and exercise correlation end to end. Ask me a follow-up if your build or startup evidence is unexpected.

+ +

✅ Takeaway

Register before publish. Match device, command ID, and correlation. Treat a missing result as unknown.

Tell me how you handled the race; we’ll carry that state into an HTTP endpoint next.

+ +
diff --git a/teaching/lessons/0008-http-acceptance-vs-completion.html b/teaching/lessons/0008-http-acceptance-vs-completion.html new file mode 100644 index 0000000..244f30c --- /dev/null +++ b/teaching/lessons/0008-http-acceptance-vs-completion.html @@ -0,0 +1,510 @@ + + + + + + + Lesson 8 — HTTP Acceptance Is Not Device Completion + + + + +
+

Go MQTT Vending Backend · Lesson 8 · 6 minutes

+

HTTP acceptance is not device completion

+

Build an honest response for a vending command that travels from an HTTP caller through your Go service and Mosquitto to a device.

+
🎯 One skill: name each acknowledgement🧠 Recall before reveal🧪 Disposable broker lab
+ +
+

❓ Start with the system story

+

A dashboard user asks the backend to ping a vending machine. Your API admitted a command record to process-local memory and published the MQTT request. The broker returned PUBACK, but the machine has not replied yet. What can you tell the caller right now?

+

Pause and answer aloud before reading on.

+
+ +

📍 Checkout status

internal/httpapi/handlers.go and server.go are Section 8 files to create; they do not yet exist in the current codebase. The complete target files and edited backend entrypoint are included below. They depend on the command service introduced in Section 7.

Lab boundary: the API binds to loopback and has no authentication. Do not expose it to another network; Section 11 adds API authentication and per-device authorization. HTTP 202 admits work into RAM only. A process restart loses command and retry-key state until Sections 13–14 add durable storage and a recoverable outbox.

+ +

🗺️ First, see the whole path

+

The API request, MQTT exchange, and device operation are separate stages. Each can succeed or fail independently.

+
+
Caller sends
POST
→ +
Backend records command
API admission
→ +
Broker replies
PUBACK
→ +
Device reports later
application result
+
+

HTTP 202 means the server accepted the request for processing; the processing is not complete. A QoS 1 PUBACK is evidence for its MQTT publication exchange. The device's later result is application evidence. The API should return a command ID now and let the caller query status later. Keep the evidence map nearby ↗

+ +

🔎 Then, read the function through its purpose

+
+
+

📄 What the file does

+

The new internal/httpapi/handlers.go will translate HTTP input into command-service calls and command records into HTTP responses. It will not publish MQTT packets itself.

+
+
+

🚀 `createCommand` — short summary

+

Validates the request and idempotency key, asks the command service to admit one command, then returns its ID and current evidence.

+
+
+
+

🧩 Details that affect behavior

+
    +
  • POST /devices/{deviceID}/commands identifies the operation and device. The handler validates the path ID and command before creating work.
  • +
  • Idempotency-Key lets a caller retry after a lost HTTP response without intentionally creating a second command. The key is not an MQTT packet ID or the command ID.
  • +
  • The command service owns the command record and MQTT publish. Keeping MQTT out of the handler makes the HTTP boundary easier to inspect and replace.
  • +
  • HTTP 202 can include the command ID and a Location pointing to GET /commands/{id}. That status resource reports the latest evidence known by the backend.
  • +
  • In this lab, retry keys and command records are in memory. A process restart loses them; later persistence lessons add a durable boundary.
  • +
+
+ +
+

📦 `writeCommand` — shape the status response

+

Summary: Sets response metadata, writes the chosen HTTP status, then encodes a command record as JSON.

+
type commandResponse struct {
+    CommandID   string `json:"command_id"`
+    DeviceID    string `json:"device_id"`
+    Status      string `json:"status"`
+    BrokerAcked bool   `json:"broker_acked"`
+}
+
+func writeCommand(w http.ResponseWriter, record commands.Record, status int) {
+    w.Header().Set("Content-Type", "application/json")
+    w.Header().Set("Location", "/commands/"+record.CommandID)
+    w.WriteHeader(status)
+    _ = json.NewEncoder(w).Encode(commandResponse{
+        CommandID: record.CommandID,
+        DeviceID: record.DeviceID,
+        Status: string(record.Status),
+        BrokerAcked: record.BrokerAcked,
+    })
+}
+
+
+

Details: Location tells the client where to read status. The HTTP status is an argument because the same mapper serves both POST admission and GET status. record.Status and BrokerAcked remain separate fields: the first is application progress, while the second records MQTT publish acknowledgement. The caller should query the returned resource for later device evidence.

+

This function excerpt focuses on response construction. The full handler file, including imports, request handling, and supporting functions, is included below.

+
+ +

The route patterns shown in this course use Go's method-aware ServeMux syntax and Request.PathValue, available from Go 1.22. This repository's go.mod targets Go 1.27.1. Go team: routing enhancements ↗

+ +

🗺️ File and function map

+

File overview: handlers.go owns HTTP routes, request validation, retry-key handling, and JSON responses. server.go builds a loopback-only server with I/O deadlines. The backend main.go wires HTTP to the command service while continuing to receive MQTT results and expire old commands.

+
+
+

📄 internal/httpapi/handlers.go

+

Function summaries: NewAPI stores dependencies; Handler registers the two routes; createCommand validates and admits a request; getCommand reads current status; decodeRequest bounds and strictly decodes JSON; writeCommand shapes the JSON response; validPathID checks safe path IDs; newCommandID generates a random stable ID.

+

🔎 Details

+
    +
  • createCommand validates device, idempotency key, body size/shape, and allowed command before calling the service. It hashes device plus command as a request fingerprint. Reusing the same key and same input returns the saved record; reusing it with different input returns conflict.
  • +
  • The retry map is protected by a mutex, and this simple implementation keeps the lock during service admission. That makes same-key retries atomic in one process, but serializes concurrent creates and loses entries on restart.
  • +
  • If the service returns a record plus a publish error, the handler still stores that record and returns 202: the command exists, but its publish outcome may be uncertain. The status body keeps status and broker_acked separate.
  • +
  • decodeRequest caps the body, rejects unknown JSON fields, and requires exactly one JSON value. writeCommand sets Location before writing the status and body; after WriteHeader, headers cannot be changed.
  • +
+
+
+

🌐 internal/httpapi/server.go

+

NewServer summary: accepts only a loopback address with an explicit port and attaches the API handler plus finite read/write/idle timeouts.

+

🔎 Details

Loopback keeps this teaching endpoint local; it is not an internet-facing production server. Header, body, response, and idle timeouts limit resource use from slow clients. Production exposure would need an intentional network binding, authentication/authorization, TLS termination, and operational configuration.

+
+
+

🔌 cmd/backend/main.go

+

run summary: starts MQTT, wires store → command service → HTTP API, runs HTTP in a goroutine, processes MQTT results and expiry ticks, then shuts down HTTP on cancellation.

+

main summary: reports a startup/runtime error and exits nonzero.

+

🔎 Details

MQTT starts before the API is exposed, so a request cannot be accepted when the command publisher has no initialized connection manager. The main select loop continues processing response messages while HTTP serves requests; otherwise commands could never advance to device-reported results. The ticker changes pending/accepted commands to unknown after deadlines. On a signal, Server.Shutdown stops accepting new HTTP requests and waits up to five seconds for in-flight handlers. The in-memory store still loses all status on process restart.

+
+
+ +

🧪 Check your model

+

Choose the evidence each event actually provides. Options in each question have the same word count; the correct answer moves between positions.

+ +
+
+ 1. What does HTTP 202 support? + + + +
+ + +
+ +
+
+ 2. What does a successful QoS 1 PUBACK establish? + + + +
+ + +
+ +
+
+ 3. What can GET /commands/{id} return later? + + + +
+ + +
+ +

🛠️ Your tiny design task

+
+

Open the Section 8 implementation and find writeCommand. Without changing code yet, write the response a caller should see immediately after command admission:

+
    +
  1. Which HTTP status should it return?
  2. +
  3. Which stable identifier should the caller save?
  4. +
  5. What URL should the Location header name?
  6. +
  7. Which status field must not be described as “completed” just because PUBACK arrived?
  8. +
+

Then compare your answer to the existing implementation in Section 8 — Expose device commands through HTTP ↗. If it matches, explain why in your own words. If it does not, identify which component owns the evidence.

+
+ + +
📦 Section 8 complete HTTP files

Complete source files for this lesson are included here. The code blocks earlier in the lesson focus on the behavior being taught; use these full files when implementing it.

📄 internal/httpapi/handlers.go

package httpapi
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"sync"
+
+	"example.com/mqtt5-learning/internal/commands"
+)
+
+const maxRequestBytes = 4 * 1024
+
+// The API depends on command operations, not on the MQTT client itself.
+type commandService interface {
+	CreateAndPublish(ctx context.Context, deviceID, commandID string) (commands.Record, error)
+	Get(commandID string) (commands.Record, bool)
+}
+
+type retryEntry struct {
+	fingerprint [32]byte
+	record      commands.Record
+}
+
+type API struct {
+	commands   commandService
+	serviceCtx context.Context
+	mu         sync.Mutex
+	retries    map[string]retryEntry
+}
+
+// NewAPI keeps retries in memory and ties accepted work to the app lifecycle.
+func NewAPI(serviceCtx context.Context, service commandService) *API {
+	return &API{commands: service, serviceCtx: serviceCtx, retries: make(map[string]retryEntry)}
+}
+
+type createRequest struct {
+	Command string `json:"command"`
+}
+type commandResponse struct {
+	CommandID   string `json:"command_id"`
+	DeviceID    string `json:"device_id"`
+	Status      string `json:"status"`
+	BrokerAcked bool   `json:"broker_acked"`
+	Detail      string `json:"detail,omitempty"`
+}
+
+func (a *API) Handler() http.Handler {
+	// Keep the public surface small: create commands and read their status.
+	mux := http.NewServeMux()
+	mux.HandleFunc("POST /devices/{deviceID}/commands", a.createCommand)
+	mux.HandleFunc("GET /commands/{commandID}", a.getCommand)
+	return mux
+}
+
+func (a *API) createCommand(w http.ResponseWriter, r *http.Request) {
+	// Validate caller input before reserving an idempotency key or publishing.
+	deviceID := r.PathValue("deviceID")
+	if !validPathID(deviceID) {
+		http.Error(w, "invalid device ID", http.StatusBadRequest)
+		return
+	}
+	key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
+	if key == "" || len(key) > 128 {
+		http.Error(w, "valid Idempotency-Key required", http.StatusBadRequest)
+		return
+	}
+	var input createRequest
+	if err := decodeRequest(w, r, &input); err != nil {
+		var tooLarge *http.MaxBytesError
+		if errors.As(err, &tooLarge) {
+			http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
+			return
+		}
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	if input.Command != "ping" {
+		http.Error(w, "unsupported command", http.StatusBadRequest)
+		return
+	}
+	// The fingerprint detects reuse of one key for different request content.
+	fingerprint := sha256.Sum256([]byte(deviceID + "\x00" + input.Command))
+
+	// This in-memory lesson serializes admissions to make same-key retries atomic.
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	if previous, ok := a.retries[key]; ok {
+		if previous.fingerprint != fingerprint {
+			http.Error(w, "idempotency key reused with different request", http.StatusConflict)
+			return
+		}
+		writeCommand(w, previous.record, http.StatusAccepted)
+		return
+	}
+	commandID, err := newCommandID()
+	if err != nil {
+		http.Error(w, "could not create command ID", http.StatusInternalServerError)
+		return
+	}
+	record, publishErr := a.commands.CreateAndPublish(a.serviceCtx, deviceID, commandID)
+	if record.CommandID == "" {
+		if publishErr != nil {
+			http.Error(w, "command admission unavailable", http.StatusServiceUnavailable)
+			return
+		}
+		http.Error(w, "command admission failed", http.StatusServiceUnavailable)
+		return
+	}
+	a.retries[key] = retryEntry{fingerprint: fingerprint, record: record}
+	// The command record exists even if the publish outcome is uncertain.
+	writeCommand(w, record, http.StatusAccepted)
+}
+
+func (a *API) getCommand(w http.ResponseWriter, r *http.Request) {
+	record, ok := a.commands.Get(r.PathValue("commandID"))
+	if !ok {
+		http.Error(w, "command not found", http.StatusNotFound)
+		return
+	}
+	writeCommand(w, record, http.StatusOK)
+}
+
+// decodeRequest enforces a byte limit, strict fields, and exactly one JSON value.
+func decodeRequest(w http.ResponseWriter, r *http.Request, target any) error {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
+	decoder := json.NewDecoder(r.Body)
+	decoder.DisallowUnknownFields()
+	if err := decoder.Decode(target); err != nil {
+		return fmt.Errorf("invalid JSON request: %w", err)
+	}
+	var trailing any
+	if err := decoder.Decode(&trailing); err != io.EOF {
+		if err == nil {
+			return errors.New("request must contain one JSON value")
+		}
+		return fmt.Errorf("trailing request data: %w", err)
+	}
+	return nil
+}
+
+func writeCommand(w http.ResponseWriter, record commands.Record, status int) {
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Location", "/commands/"+record.CommandID)
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(commandResponse{CommandID: record.CommandID, DeviceID: record.DeviceID, Status: string(record.Status), BrokerAcked: record.BrokerAcked, Detail: record.Detail})
+}
+
+// Path IDs use the same restricted alphabet as contract and topic IDs.
+func validPathID(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
+}
+
+func newCommandID() (string, error) {
+	var raw [16]byte
+	if _, err := rand.Read(raw[:]); err != nil {
+		return "", err
+	}
+	return "cmd-" + hex.EncodeToString(raw[:]), nil
+}
+
+

📄 internal/httpapi/server.go

package httpapi
+
+import (
+	"fmt"
+	"net"
+	"net/http"
+	"time"
+)
+
+func NewServer(address string, api *API) (*http.Server, error) {
+	// The teaching API listens only on loopback and uses finite I/O timeouts.
+	host, port, err := net.SplitHostPort(address)
+	if err != nil || (host != "127.0.0.1" && host != "localhost") || port == "" {
+		return nil, fmt.Errorf("HTTP lab address must be loopback with an explicit port")
+	}
+	return &http.Server{
+		Addr: address, Handler: api.Handler(),
+		ReadHeaderTimeout: 5 * time.Second,
+		ReadTimeout:       10 * time.Second,
+		WriteTimeout:      10 * time.Second,
+		IdleTimeout:       60 * time.Second,
+	}, nil
+}
+
+

📄 cmd/backend/main.go

package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"example.com/mqtt5-learning/internal/commands"
+	"example.com/mqtt5-learning/internal/config"
+	"example.com/mqtt5-learning/internal/contracts"
+	"example.com/mqtt5-learning/internal/httpapi"
+	"example.com/mqtt5-learning/internal/mqtt"
+)
+
+func main() {
+	if err := run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func run() error {
+	cfg, err := config.Load(os.Args[1:])
+	if err != nil {
+		return fmt.Errorf("load config: %w", err)
+	}
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	inbox := make(chan mqtt.Message, 16)
+	telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry)
+	if err != nil { return err }
+	responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses)
+	if err != nil { return err }
+	salesFilter, err := contracts.DeviceFilter(contracts.KindSales)
+	if err != nil { return err }
+	topics := []contracts.TopicFilter{telemetryFilter, responsesFilter, salesFilter}
+	manager, err := mqtt.Start(ctx, cfg, topics, inbox)
+	if err != nil {
+		return fmt.Errorf("start MQTT: %w", err)
+	}
+	defer mqtt.Stop(manager)
+
+	// Wire the command service before exposing its HTTP endpoints.
+	store := commands.NewStore()
+	publisher := mqtt.CommandPublisher{Manager: manager}
+	service := commands.NewService(store, publisher, 15*time.Second)
+	api := httpapi.NewAPI(ctx, service)
+	server, err := httpapi.NewServer("127.0.0.1:18080", api)
+	if err != nil {
+		return err
+	}
+	serverErrors := make(chan error, 1)
+	go func() { serverErrors <- server.ListenAndServe() }()
+	log.Printf("HTTP API listening on %s", server.Addr)
+	expirationTicker := time.NewTicker(time.Second)
+	defer expirationTicker.Stop()
+
+	// Keep response processing and expiration active for the server lifetime.
+	for {
+		select {
+		case <-ctx.Done():
+			shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+			defer cancel()
+			if err := server.Shutdown(shutdown); err != nil {
+				return fmt.Errorf("HTTP shutdown: %w", err)
+			}
+			return nil
+		case err := <-serverErrors:
+			if !errors.Is(err, http.ErrServerClosed) {
+				return fmt.Errorf("HTTP server: %w", err)
+			}
+			return nil
+		case <-expirationTicker.C:
+			service.Expire(time.Now().UTC())
+		case message := <-inbox:
+			topic, err := contracts.ParseTopic(message.Topic)
+			if err != nil {
+				log.Printf("rejected topic=%q reason=%v", message.Topic, err)
+				continue
+			}
+			switch topic.Kind {
+			case contracts.KindResponses:
+				if err := commands.HandleMessage(service, message); err != nil {
+					log.Printf("rejected response topic=%q reason=%v", message.Topic, err)
+				}
+			case contracts.KindTelemetry:
+				var heartbeat contracts.Heartbeat
+				heartbeat, err = contracts.Decode[contracts.Heartbeat](message.Payload, topic, time.Now().UTC())
+				if err != nil {
+					log.Printf("rejected telemetry topic=%q reason=%v", message.Topic, err)
+					continue
+				}
+				log.Printf("valid heartbeat device=%s message=%s", heartbeat.DeviceID, heartbeat.MessageID)
+			case contracts.KindSales:
+				var report contracts.SalesReport
+				report, err = contracts.Decode[contracts.SalesReport](message.Payload, topic, time.Now().UTC())
+				if err != nil {
+					log.Printf("rejected sales topic=%q reason=%v", message.Topic, err)
+					continue
+				}
+				log.Printf("valid sales report device=%s report=%s", report.DeviceID, report.ReportID)
+			default:
+				log.Printf("ignored topic kind=%s", topic.Kind)
+			}
+		}
+	}
+}
+
+ +

🏗️ Build and run the HTTP-to-device path

+

After saving the Section 7 command files plus the Section 8 HTTP files and backend wiring, build and start the lab from the repository root:

+
go test ./...
+go build -o /tmp/mqtt5-lab-backend ./cmd/backend
+go build -o /tmp/mqtt5-lab-simulator ./cmd/simulator
+docker compose -p mqtt5-learning up -d broker
+docker compose -p mqtt5-learning ps
+

The tests cover only the tests present in the checkout; the builds verify the complete API and simulator programs compile. Run the simulator and backend in separate terminals:

+
/tmp/mqtt5-lab-simulator -username simulator -client-id lab-simulator-device-001
+
/tmp/mqtt5-lab-backend -username backend -client-id lab-backend
+

Once the backend reports that the HTTP API is listening, submit a harmless ping. Copy the returned command_id into the GET request:

+
curl -i -X POST http://127.0.0.1:18080/devices/device-001/commands \
+  -H 'Content-Type: application/json' \
+  -H 'Idempotency-Key: lesson8-ping-001' \
+  --data '{"command":"ping"}'
+
+# Replace cmd-example with the command_id returned by POST.
+curl -i http://127.0.0.1:18080/commands/cmd-example
+

Expect HTTP 202 from POST, then later application status from GET after the simulator responds. Repeat the same POST with the same idempotency key and body; it should return the same command ID. PUBACK alone is not device completion. Stop both clients with Ctrl+C, then stop the broker with docker compose -p mqtt5-learning stop broker. Ask me a follow-up if the observed status or retry result differs from your prediction.

+ +
+

✅ Takeaway

+

202 = API admission. PUBACK = MQTT publication acknowledgement. Device result = application-reported outcome.

+

Keep those facts separate in logs, API fields, dashboards, and incident reports. Ask me a follow-up question if any boundary still feels fuzzy.

+
+ + +
+ + diff --git a/teaching/lessons/0009-session-recovery.html b/teaching/lessons/0009-session-recovery.html new file mode 100644 index 0000000..ef0177a --- /dev/null +++ b/teaching/lessons/0009-session-recovery.html @@ -0,0 +1,181 @@ + + + + + Lesson 9 — MQTT Sessions and Recovery + + +
+

Go MQTT Vending Backend · Lesson 9 · 10 minutes

+

Recover broker session work across disconnects

+

Make MQTT session policy visible in the connection code, then test what survives network loss and process restart.

+
🎯 One skill: trace session ownership🧠 Predict reconnect behavior🧪 Disposable broker lab
+

❓ Start with the system story

A simulator disconnects after subscribing. The backend publishes a QoS 1 ping. What must the broker still have when the simulator reconnects?

Predict the Client ID, session expiry, subscription, and CONNACK Session Present before reading the implementation.

+

🗺️ File overview

+

internal/mqtt/client.go owns the AutoPaho connection, MQTT subscriptions, and the inbox callback. This lesson edits that file to use a stable-session profile and record the broker's CONNACK evidence. The backend and simulator already load distinct, stable Client IDs from configuration.

+

🔌 Start — short summary

+

Creates AutoPaho with Clean Start disabled for the initial connection and a 60-second Session Expiry Interval. It logs Session Present on every connection and restores the configured subscriptions.

+

📤 Publish and Stop — short summary

+

Publish sends QoS 1 and checks the PUBACK reason. Stop performs a bounded graceful disconnect.

+

🧩 Complete file: internal/mqtt/client.go

+

Replace the file with this full version. It preserves the lesson's bounded inbox and adds session logging; it does not make application command records durable.

+
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
+}
+
+const sessionExpirySeconds uint32 = 60
+
+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, fmt.Errorf("parse broker URL: %w", err)
+	}
+	initialSubscription := make(chan error, 1)
+	manager, err := autopaho.NewConnection(ctx, autopaho.ClientConfig{
+		ServerUrls:                    []*url.URL{broker},
+		KeepAlive:                     20,
+		CleanStartOnInitialConnection: false, // Resume this Client ID's session when one exists.
+		SessionExpiryInterval:         sessionExpirySeconds,
+		ConnectUsername:               cfg.MQTT.Username,
+		ConnectPassword:               []byte(cfg.MQTT.Password),
+		ClientID:                      cfg.MQTT.ClientID,
+		OnConnectionUp: func(cm *autopaho.ConnectionManager, ack *paho.Connack) {
+			if ack != nil {
+				log.Printf("mqtt connected client_id=%s session_present=%t", cfg.MQTT.ClientID, ack.SessionPresent)
+			}
+			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,
+				})
+			}
+			suback, subErr := cm.Subscribe(subCtx, &paho.Subscribe{Subscriptions: subscriptions})
+			if subErr == nil {
+				if suback == nil || len(suback.Reasons) != len(topics) {
+					subErr = fmt.Errorf("missing or incomplete SUBACK")
+				} else {
+					for i, reason := range suback.Reasons {
+						if reason > 1 {
+							subErr = fmt.Errorf("subscription %s rejected: 0x%02x", topics[i], reason)
+							break
+						}
+					}
+				}
+			}
+			if subErr != nil {
+				log.Printf("mqtt subscription failed: %v", subErr)
+			} else {
+				log.Printf("mqtt subscriptions ready client_id=%s count=%d", cfg.MQTT.ClientID, len(topics))
+			}
+			select {
+			case initialSubscription <- subErr:
+			default: // Reconnect callbacks must never block on this one-time startup signal.
+			}
+		},
+		OnConnectionDown: func() bool {
+			log.Printf("mqtt connection down client_id=%s; reconnecting", cfg.MQTT.ClientID)
+			return true
+		},
+		OnConnectError: func(err error) { log.Printf("mqtt connect error: %v", err) },
+		OnClientError: func(err error) { log.Printf("mqtt client error: %v", err) },
+		OnServerDisconnect: func(packet *paho.Disconnect) {
+			log.Printf("mqtt server disconnect reason=0x%02x", packet.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:
+					// Automatic acknowledgement may still complete this delivery.
+					log.Printf("mqtt inbox full; application 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 MQTT connection: %w", err)
+	}
+	select {
+	case err := <-initialSubscription:
+		if err != nil {
+			Stop(manager)
+			return nil, fmt.Errorf("await MQTT subscriptions: %w", err)
+		}
+	case <-startup.Done():
+		Stop(manager)
+		return nil, fmt.Errorf("await MQTT 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,
+		Properties: &paho.PublishProperties{ContentType: "application/json"},
+	})
+	if err != nil {
+		return fmt.Errorf("publish: %w", err)
+	}
+	if ack == nil {
+		return fmt.Errorf("missing QoS 1 publish acknowledgement")
+	}
+	if ack.ReasonCode >= 0x80 {
+		return fmt.Errorf("publish rejected: reason=0x%02x", ack.ReasonCode)
+	}
+	log.Printf("mqtt publish acknowledged topic=%s reason=0x%02x", topic, ack.ReasonCode)
+	return nil
+}
+
+func Stop(manager *autopaho.ConnectionManager) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	if err := manager.Disconnect(ctx); err != nil {
+		log.Printf("MQTT shutdown did not finish within deadline: %v", err)
+	}
+}
+
+

🔎 Details: the session guarantees

  • CleanStartOnInitialConnection: false asks AutoPaho to resume this Client ID's session when available. AutoPaho uses Clean Start false on subsequent reconnects regardless; process restart creates a new manager and applies the configured initial behavior again.
  • SessionExpiryInterval is seconds. A 60-second session can disappear after the interval or broker loss if persistence is disabled.
  • SessionPresent reports broker session resumption only. This callback logs it separately from subscription readiness.
  • Subscribing again on connection-up is deliberate for a simple lab, but duplicate SUBSCRIBE behavior must be observed. It does not register another local Go callback.
  • The helper still uses automatic acknowledgement and a RAM inbox. Session continuity cannot repair a message dropped after callback admission, and it cannot restore the backend's in-memory command record.
+

🧪 Run the experiment matrix

+

Rebuild both programs after changing the constant or Clean Start setting. Use one simulator per Client ID. Record prediction, broker settings, CONNACK Session Present, SUBACK results, and command delivery in journal/09-sessions.md.

+
  1. Connect and subscribe, stop the simulator gracefully, publish a QoS 1 ping while it is offline, then reconnect with the same Client ID before 60 seconds.
  2. Repeat with Clean Start true on the initial connection, then with a changed Client ID, then after the expiry interval.
  3. Compare broker restart with persistence disabled and enabled, using the isolated Compose override described in the full source lesson.
  4. Keep the backend command ID and simulator effect counter separate from Session Present.
+

🧭 Session queue versus retained latest value

+
Delivery mechanismWhat it representsWho can receive it later
Queued nonretained QoS 1 publicationOne event for an existing matching session subscriptionThat session before expiry, if broker state and QoS permit
Retained publicationThe latest value stored for a topicA new subscriber, even with a fresh Client ID
+

Safe lab comparison: After the queued-ping run, reconnect the original simulator and keep it online so the retained probe cannot also queue in its offline session. Publish a synthetic retained ping to its lab command topic with the authorized lab backend identity; the Lesson 7 simulator must reject it before any effect. Subscribe a fresh observer Client ID with the lab simulator identity; it should receive the stored value immediately without a prior session. Clear it immediately with an empty retained publication (`mosquitto_pub -r -n`), then verify a new subscription gets no replay. Use no physical device or production credential, and do not leave retained commands in the lab.

A retained message is the latest value, not event history. Do not use retained publications for one-shot commands.

+

✅ Done when

You can explain which subscriptions and eligible queued messages the broker preserved and which Go state was lost. Session Present is never used as proof of command completion.

+
📋 Short self-check answers
  1. Stable Client ID, resumable unexpired session, matching QoS subscription, eligible nonexpired QoS publication, and available broker state.
  2. Session Present means the broker resumed session state, not application memory or device effect.
  3. A fresh session after process restart is different from an automatic reconnect within the existing manager.
+

📚 Full session experiment details: Section 9 source lesson. Primary references: OASIS MQTT 5.0 Standard and Paho AutoPaho v0.23.0 API.

+

If the observed session behavior differs from your prediction, ask me to trace the CONNECT/CONNACK and SUBSCRIBE/SUBACK evidence with you.

+ +
diff --git a/teaching/lessons/0010-expiry-presence-state.html b/teaching/lessons/0010-expiry-presence-state.html new file mode 100644 index 0000000..a642ecc --- /dev/null +++ b/teaching/lessons/0010-expiry-presence-state.html @@ -0,0 +1,290 @@ + + + + + Lesson 10 — Expiry, Presence, and Desired State + + +
+

Go MQTT Vending Backend · Lesson 10 · 12 minutes

+

Give stale work an expiry; treat presence as evidence

+

Use separate clocks for publication lifetime, application deadlines, sessions, and freshness. Keep desired state distinct from one-shot commands.

+
🎯 One skill: assign timer ownership🧠 Predict stale delivery🧪 Simulator + broker
+

❓ Start with the system story

A vending machine has been offline for an hour. A queued “apply price update” arrives after it reconnects, but the price is obsolete. Which timer prevents stale delivery, and which check prevents late execution after delivery?

Answer before reading: broker policy and device policy are separate checks.

+

⏱️ One timer, one responsibility

+

MQTT Message Expiry

Bounds how long a PUBLISH remains eligible for onward delivery. The broker may discard expired queued work.

Application deadline

An absolute deadline in the command contract lets the device reject work that is already delivered but too old to execute.

Session Expiry

Controls how long the broker may preserve the client's session after disconnect.

Heartbeat freshness

Backend receive time measures how recently an application report arrived. It is not a broker connection probe or health diagnosis.

+

Keep Alive detects a lack of MQTT control traffic; it does not show that the application is producing heartbeats. A publish context timeout ends your wait, not necessarily the remote operation. Will Delay delays an eligible Will; it does not certify why the device disappeared.

+
+

🧠 Check the boundary: What combination protects against a command that waits in the broker and then spends time in a local queue?

+


+
+

📌 Retained desired state is not an event queue

+

A retained publication stores the latest value for one topic and can initialize a new subscriber. It is useful for replaceable desired configuration, such as catalog revision 9. A retained “dispense” or “run calibration once” action can replay to a later subscriber, so one-shot commands should be nonretained.

+

For desired state, keep the highest revision at the simulator and reject revision 8 after revision 9. The device reports the revision it applied. A PUBACK only confirms the relevant QoS hop; it does not prove application. An empty retained publication removes the stored value for that topic; it does not retract copies already delivered.

+

📄 Complete file: internal/contracts/topic.go

+

File overview: This is the single source of truth for concrete publish names, controlled subscription filters, and inbound topic parsing. The added kinds support presence and desired/reported state.

+

DeviceTopic / DeviceFilter summary: Build validated exact names and fleet filters. ParseTopic summary: Derive the device and kind from the shared prefix and reject unknown routes.

+
🧩 Full replacement source
package contracts
+
+import (
+	"fmt"
+	"strings"
+)
+
+type TopicName string
+type TopicFilter string
+type Kind string
+
+const (
+	KindTelemetry Kind = "telemetry"
+	KindCommands  Kind = "commands"
+	KindResponses Kind = "responses"
+	KindSales     Kind = "sales"
+	KindPresence  Kind = "presence"
+	KindDesired   Kind = "desired"
+	KindReported  Kind = "reported"
+)
+
+const topicPrefix = "lab/v1/devices/"
+
+type Topic struct {
+	DeviceID string
+	Kind     Kind
+}
+
+func (t TopicName) String() string   { return string(t) }
+func (f TopicFilter) String() string { return string(f) }
+
+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
+}
+
+func DeviceFilter(kind Kind) (TopicFilter, error) {
+	if !supportedKind(kind) {
+		return "", fmt.Errorf("unsupported topic kind %q", kind)
+	}
+	return TopicFilter(topicPrefix + "+/" + string(kind)), nil
+}
+
+func DeviceSubscription(deviceID string, kind Kind) (TopicFilter, error) {
+	topic, err := DeviceTopic(deviceID, kind)
+	if err != nil {
+		return "", err
+	}
+	return TopicFilter(topic.String()), nil
+}
+
+func ParseTopic(value string) (Topic, error) {
+	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,
+		KindPresence, KindDesired, KindReported:
+		return true
+	default:
+		return false
+	}
+}
+
+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
+}
+
+

🧩 Complete file: expiry and command deadline policy

+

File overview: Create internal/mqtt/expiry.go to build a QoS 1 publication with explicit expiry, and internal/deviceview/deadline.go to reject stale commands at execution time. Each file is complete and has its own imports, types, and errors.

+
📄 internal/mqtt/expiry.go — complete file
package mqtt
+
+import (
+	"fmt"
+
+	"example.com/mqtt5-learning/internal/contracts"
+	"github.com/eclipse/paho.golang/paho"
+)
+
+// ExpiringPublish builds a publication whose broker-queued lifetime is bounded.
+// The device must still check its application deadline before executing it.
+func ExpiringPublish(topic contracts.TopicName, payload []byte, expirySeconds uint32) (*paho.Publish, error) {
+	if topic == "" {
+		return nil, fmt.Errorf("topic is required")
+	}
+	if expirySeconds == 0 {
+		return nil, fmt.Errorf("expirySeconds must be greater than zero")
+	}
+	seconds := expirySeconds
+	return &paho.Publish{
+		Topic: topic.String(), Payload: append([]byte(nil), payload...), QoS: 1,
+		Properties: &paho.PublishProperties{
+			ContentType: "application/json",
+			MessageExpiry: &seconds,
+		},
+	}, nil
+}
+
+
📄 internal/deviceview/deadline.go — complete file
package deviceview
+
+import (
+	"fmt"
+	"time"
+)
+
+// CheckCommandDeadline rejects a command before the simulator starts its effect.
+// The absolute deadline protects against time spent in local queues after delivery.
+func CheckCommandDeadline(deadline string, now time.Time) error {
+	if deadline == "" {
+		return fmt.Errorf("command deadline is required")
+	}
+	when, err := time.Parse(time.RFC3339, deadline)
+	if err != nil {
+		return fmt.Errorf("parse command deadline: %w", err)
+	}
+	if !now.Before(when) {
+		return fmt.Errorf("command deadline has passed")
+	}
+	return nil
+}
+
+

🧩 Complete file: simulator's monotonic desired state

+

File overview: Create internal/deviceview/revisions.go to keep a per-device revision and prevent older desired state from replacing newer applied state.

+

Apply summary: Lock, compare the incoming revision, and update only when it is newer. Return an explicit outcome so duplicate and stale deliveries are observable.

+
📄 internal/deviceview/revisions.go — complete file
package deviceview
+
+import "sync"
+
+type RevisionOutcome uint8
+
+const (
+	RevisionApplied RevisionOutcome = iota + 1
+	RevisionDuplicate
+	RevisionStale
+)
+
+type RevisionStore struct {
+	mu      sync.Mutex
+	current map[string]uint64
+}
+
+func NewRevisionStore() *RevisionStore {
+	return &RevisionStore{current: make(map[string]uint64)}
+}
+
+func (s *RevisionStore) Apply(deviceID string, revision uint64) RevisionOutcome {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	current, exists := s.current[deviceID]
+	if exists && revision == current {
+		return RevisionDuplicate
+	}
+	if exists && revision < current {
+		return RevisionStale
+	}
+	s.current[deviceID] = revision
+	return RevisionApplied
+}
+
+func (s *RevisionStore) Current(deviceID string) (uint64, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	revision, ok := s.current[deviceID]
+	return revision, ok
+}
+
+

🧭 File overview: internal/mqtt/presence.go

+

Create this complete helper file for the Last Will portion. OfflineWill builds the message prepared during CONNECT; OnlinePresence builds the retained publication sent after a successful connection. Both use one validated presence topic and stable device/boot identity.

+

📴 OfflineWill — short summary

Returns a retained QoS 1 offline Will with a five-second Will Delay property.

+

🟢 OnlinePresence — short summary

Creates the retained online publication for the same device and boot identity.

+
📄 internal/mqtt/presence.go — complete file
package mqtt
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"example.com/mqtt5-learning/internal/contracts"
+	"github.com/eclipse/paho.golang/paho"
+)
+
+type Presence struct {
+	SchemaVersion int    `json:"schema_version"`
+	MessageID     string `json:"message_id"`
+	DeviceID      string `json:"device_id"`
+	BootID        string `json:"boot_id"`
+	Status        string `json:"status"`
+	OccurredAt    string `json:"occurred_at"`
+}
+
+func OfflineWill(deviceID, bootID string) (*paho.WillMessage, *paho.WillProperties, error) {
+	if deviceID == "" || bootID == "" {
+		return nil, nil, fmt.Errorf("device ID and boot ID are required")
+	}
+	topic, err := contracts.DeviceTopic(deviceID, contracts.KindPresence)
+	if err != nil {
+		return nil, nil, fmt.Errorf("build presence topic: %w", err)
+	}
+	body, err := json.Marshal(Presence{
+		SchemaVersion: contracts.SchemaVersionOne,
+		MessageID: "will-" + bootID, DeviceID: deviceID, BootID: bootID,
+		Status: "offline", OccurredAt: time.Now().UTC().Format(time.RFC3339),
+	})
+	if err != nil { return nil, nil, fmt.Errorf("encode offline Will: %w", err) }
+	delay := uint32(5)
+	return &paho.WillMessage{Topic: topic.String(), QoS: 1, Retain: true, Payload: body},
+		&paho.WillProperties{WillDelayInterval: &delay, ContentType: "application/json"}, nil
+}
+
+func OnlinePresence(deviceID, bootID string, now time.Time) (*paho.Publish, error) {
+	if deviceID == "" || bootID == "" {
+		return nil, fmt.Errorf("device ID and boot ID are required")
+	}
+	topic, err := contracts.DeviceTopic(deviceID, contracts.KindPresence)
+	if err != nil { return nil, fmt.Errorf("build presence topic: %w", err) }
+	body, err := json.Marshal(Presence{
+		SchemaVersion: contracts.SchemaVersionOne,
+		MessageID: "online-" + bootID, DeviceID: deviceID, BootID: bootID,
+		Status: "online", OccurredAt: now.UTC().Format(time.RFC3339),
+	})
+	if err != nil { return nil, fmt.Errorf("encode online presence: %w", err) }
+	return &paho.Publish{
+		Topic: topic.String(), Payload: body, QoS: 1, Retain: true,
+		Properties: &paho.PublishProperties{ContentType: "application/json"},
+	}, nil
+}
+
+

🔎 Details: a Will timestamp is not a failure timestamp

The Will body is prepared at CONNECT, so OccurredAt above is the preparation time. The backend should record its own receipt time and use it when evaluating freshness. Include a boot ID so stale presence from a previous process can be distinguished. The simulator's planned shutdown must publish retained offline presence before sending normal DISCONNECT if the application needs that final state; graceful DISCONNECT suppresses the Will.

+

🧪 Predict, then observe

+
  1. Set a persistent simulator session, disconnect it, publish a QoS 1 command with a short Message Expiry Interval, then reconnect after expiry.
  2. Repeat with a longer expiry but an already-passed application deadline. The simulator should receive and reject it as stale.
  3. Publish desired revision 9, then revision 8; verify reported state remains at 9.
  4. Compare graceful shutdown, abrupt simulator termination, reconnect before Will Delay, and broker stop. Record the exact fault and evidence.
  5. Clear the retained value and subscribe fresh. Verify no retained replay.
+

❤️ Presence has limits

Retained “online” is the broker's latest stored assertion. A Last Will is published by the broker after an eligible unexpected loss; it is not sent by the dead process. A clean DISCONNECT normally suppresses the Will. Combine connection events with backend-received heartbeat freshness and device-reported health rather than treating one as the other.

What could go wrong? A stale retained online value can outlive a graceful shutdown; Will Delay may interact with session expiry; a command delivered just before its expiry may still sit in a local queue; clocks can disagree. Record backend receive time and define the device deadline policy.

+

🔎 Details: ownership and limitations

ExpiringPublish creates protocol metadata only; wire it into the existing publisher and handle the returned PUBACK as transport evidence. CheckCommandDeadline must run immediately before the simulator effect, after local queue wait. RevisionStore is intentionally RAM-only, so a simulator restart loses its revision; PostgreSQL or device flash is needed for durable state. The will is a broker publication prepared during CONNECT; normal DISCONNECT suppresses it. These helpers do not replace ACL updates, topic/body validation, or device response reporting.

+

✅ Done when

Demonstrate expired queued work, device-side stale rejection, retained replacement/deletion, stale revision rejection, and the difference between graceful and unexpected disconnect evidence.

+
📋 Short self-check answers
  1. Broker expiry limits onward delivery; an application deadline limits whether the device starts the action.
  2. Retained messages represent latest state, not historical event delivery.
  3. Heartbeat freshness says a recent report arrived, not that the device is healthy or the broker currently has a connection.
+

📚 Detailed experiments: Section 10 source lesson. Primary references: OASIS MQTT 5.0 Standard, Paho v0.23.0 API, and Mosquitto publish client manual.

+

Ask me to trace an expiry or presence result if the packet evidence differs from your prediction.

+ +
diff --git a/teaching/lessons/0011-security-boundaries.html b/teaching/lessons/0011-security-boundaries.html new file mode 100644 index 0000000..e072f8f --- /dev/null +++ b/teaching/lessons/0011-security-boundaries.html @@ -0,0 +1,71 @@ + + + + + Lesson 11 — MQTT Security Boundaries + + +
+

Go MQTT Vending Backend · Lesson 11 · 12 minutes

+

Trace security checks from socket to business action

+

TLS, MQTT credentials, topic ACLs, payload validation, and HTTP user permissions answer different questions.

+
🎯 One skill: locate a trust boundary🧠 Predict which layer rejects🧪 Negative tests
+

❓ Start with the system story

A client with a valid username publishes to another device's topic and claims that device's ID in JSON. Which component can reject the topic access, and which component checks the body claim?

Pause and trace the request from connection setup through business authorization.

+

🔐 The checks run in sequence

+
TLS
server identity + encryption
→
MQTT CONNECT
client authentication
→
Broker ACL
topic authorization
→
Go service
contract + business rules
→
HTTP API
user's device permission
+

A JSON device_id is a claim, not authenticated identity. A Client ID names a session; it is not a secret. The broker usually does not attach the MQTT publisher username to a forwarded PUBLISH, so bind unique client credentials to narrow topic ACLs, then validate topic/body agreement in the application.

+
+

🧠 Check the boundary: Which check enforces that a device credential can publish only to its assigned device namespace?

+


+
+

🌐 Verify the broker before sending credentials

+

For TLS, the certificate chain must lead to a trusted CA and its subject alternative name must match the host/IP used by the client. A trusted certificate for broker.lab can still fail when the client verifies 127.0.0.1. Correct the URL or certificate identity; never turn off certificate verification to make the experiment pass.

+

Server TLS encrypts the channel and verifies the broker. Username/password authenticates the MQTT client. Per-device ACLs authorize topics. Mutual TLS adds client-certificate authentication only when the broker maps that identity to a deliberate policy. These choices do not authorize an HTTP user to operate a particular vending machine.

+

📄 Complete new file: internal/mqtt/tls.go

+

File overview: This file reads a public CA certificate and builds a client TLS configuration that verifies both the chain and broker hostname. It does not load client private keys or disable verification.

+

NewVerifiedTLSConfig summary: Rejects empty inputs and invalid PEM, then returns TLS 1.2+ settings with an explicit ServerName.

+

Details: The caller supplies the DNS name or IP address used by the broker certificate SAN. This lesson's implementation is deliberately a complete, standalone trust-config helper; it does not replace the existing MQTT client or config loader. The CA file is public trust material; never embed a private CA key in the client.

+
🧩 Full file contents
package mqtt
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"os"
+	"strings"
+)
+
+// NewVerifiedTLSConfig builds a client config that verifies the broker's
+// certificate chain and the exact DNS name or IP used by the client.
+func NewVerifiedTLSConfig(caPath, serverName string) (*tls.Config, error) {
+	if strings.TrimSpace(caPath) == "" {
+		return nil, fmt.Errorf("CA certificate path is required")
+	}
+	if strings.TrimSpace(serverName) == "" {
+		return nil, fmt.Errorf("broker server name is required")
+	}
+	caPEM, err := os.ReadFile(caPath)
+	if err != nil {
+		return nil, fmt.Errorf("read broker CA: %w", err)
+	}
+	roots := x509.NewCertPool()
+	if !roots.AppendCertsFromPEM(caPEM) {
+		return nil, fmt.Errorf("broker CA file contains no valid certificates")
+	}
+	return &tls.Config{
+		RootCAs: roots,
+		ServerName: serverName,
+		MinVersion: tls.VersionTLS12,
+	}, nil
+}
+
+

Integration exercise: In the existing autopaho.ClientConfig, pass this helper's result through TlsCfg and make the configured broker URL and certificate SAN name agree. Treat changes to the client and config loader as separate files: show each whole updated file in your work, including imports and validation, rather than pasting a partial field diff. Do not use InsecureSkipVerify. Until both files are updated, this helper alone does not make the running client use TLS.

+

🧪 Change one input at a time

+
ChangeExpected boundaryEvidence
Wrong CATLS trustNo authenticated MQTT session
Wrong certificate hostnameTLS server identityName mismatch
Wrong passwordCONNECT authenticationConnection rejected
Valid login, cross-device topicBroker ACLDenied publish/subscribe and broker evidence
Allowed topic, body ID mismatchApplication validationRejected before state mutation
Valid HTTP user, unauthorized targetAPI authorizationDenied before command creation/publication
+

📬 Response Topic is also a trust boundary

The simulator should compare a command's Response Topic with its exact authorized response topic before publishing. Correlation Data helps match a response to a request; it does not authorize the destination or authenticate the publisher. An application-level denial also does not reverse an MQTT acknowledgement already sent.

Rotate credentials by verifying fresh connections with both old and new credentials and checking subscription restoration. Updating a password file alone does not prove existing connections were closed.

+

✅ Done when

Run the negative matrix in the disposable lab and show that each failure is rejected at its intended boundary. A broad # ACL is not a fix for an authorization failure.

+
📋 Short self-check answers
  1. Broker ACL: which authenticated MQTT client may use which topic.
  2. Application: whether the delivered topic and payload claims agree and whether the operation is valid.
  3. API authorization: whether this HTTP principal may act on the requested device.
+

📚 Full failure matrix: Section 11 source lesson. Primary references: Go TLS Config API, Mosquitto configuration manual, and OASIS MQTT 5.0 Standard.

+

Ask me to inspect a TLS or ACL failure with you; the layer and evidence should agree.

+ +
diff --git a/teaching/lessons/0011b-dynamic-security.html b/teaching/lessons/0011b-dynamic-security.html new file mode 100644 index 0000000..13ab049 --- /dev/null +++ b/teaching/lessons/0011b-dynamic-security.html @@ -0,0 +1,76 @@ + + + + + Lesson 11B — Mosquitto Dynamic Security + + +
+

Go MQTT Vending Backend · Lesson 11B · Optional extension

+

Provision per-device broker permissions at runtime

+

Use Mosquitto Dynamic Security to create device clients and roles without editing static password and ACL files.

+
🎯 One skill: separate provisioning from runtime traffic🧠 Predict revocation behavior🧪 Isolated broker config
+

❓ Start with the provisioning story

A new vending machine is installed after Mosquitto starts. How can it receive an individual credential and narrow topic permissions while the broker is running?

Name the privileged actor and the separate device identity before continuing.

+

🧭 Three identities, three responsibilities

+
  1. Provisioning operator/service: authenticates with a protected management identity and uses the Dynamic Security control API.
  2. Device client: receives its own account and a role limited to its topic namespace.
  3. Backend: receives ordinary device publications, validates their contracts, and applies application policy. It should not hold broker-administrator credentials just to process traffic.
+

Dynamic Security is a Mosquitto plugin and broker control API, not a Go MQTT 5 library feature. The plugin manages clients, groups, roles, and ACLs and persists its configuration state. The ordinary backend/device listener and a separate management identity keep operations clearly scoped.

+
+

🧠 Check the boundary: Which credential should the device use to publish telemetry?

+


+
+

🧱 Keep the extension isolated

+

The normal course lab uses Mosquitto password and ACL files and does not load this plugin. Create a separate broker configuration and persistent, broker-writable Dynamic Security state file. Verify the plugin path inside the pinned image. Do not silently mix plugin-managed accounts with the existing static authentication setup.

+

📄 Complete broker configuration: lab/dynsec/mosquitto.conf

+

File overview: This isolated configuration loads only Dynamic Security for the lab listener, disables anonymous access, and stores plugin state under the persistent broker data mount. The Linux plugin path below is the standard Debian multiarch path; confirm it inside the exact image before starting the comparison broker because the container build may package it elsewhere.

+
🧩 Full configuration file
listener 1883
+allow_anonymous false
+per_listener_settings false
+plugin /usr/lib/x86_64-linux-gnu/mosquitto_dynamic_security.so
+plugin_opt_config_file /mosquitto/data/dynamic-security.json
+persistence true
+persistence_location /mosquitto/data/
+log_dest stdout
+connection_messages true
+
+

Before writing that file, inspect the pinned image's plugin path with a one-off container command such as docker run --rm --entrypoint sh eclipse-mosquitto:2.0.22 -c 'find /usr -name mosquitto_dynamic_security.so -print'. The command searches inside the image and removes the temporary container; it does not start or modify the course broker. If the path differs, update the single plugin line and record the observed path. I could not inspect this host's Docker image because the Docker socket denied access.

+

🪪 Complete device and backend role rules

+

File overview: These are the full topic permissions to install through the management API. Device ACL patterns bind the authenticated username as one device-ID segment. Backend permissions are fleet scoped. Set receive default-deny explicitly before grants.

+
📋 Role matrix and complete control commands
# These commands run against the dedicated Dynamic Security broker.
+# Supply the admin secret through your protected local shell/session.
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec setDefaultACLAccess publishClientReceive deny
+
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec createRole device
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec addRoleACL device publishClientSend 'lab/v1/devices/%u/telemetry' allow 10
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec addRoleACL device publishClientSend 'lab/v1/devices/%u/responses' allow 10
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec addRoleACL device subscribePattern 'lab/v1/devices/%u/commands' allow 10
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec addRoleACL device publishClientReceive 'lab/v1/devices/%u/commands' allow 10
+
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec createRole backend
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec addRoleACL backend publishClientSend 'lab/v1/devices/+/commands' allow 10
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec addRoleACL backend subscribePattern 'lab/v1/devices/+/telemetry' allow 10
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec addRoleACL backend publishClientReceive 'lab/v1/devices/+/telemetry' allow 10
+
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec createClient device-001 -i lab-device-001
+mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
+  dynsec addClientRole device-001 device 10
+

Run the commands in order and only once per role/ACL. The client-creation command prompts for the device password. For a repeated setup, query existing plugin state first and handle already-created roles/ACLs explicitly rather than widening access.

+

Start with default-deny behavior for the relevant direction, grant only the required device publish/backend subscribe paths, and test real message delivery. A successful SUBACK or an ACL line alone does not prove a cross-device publication is blocked.

+

🧪 Provision, test, revoke

+
  1. Create the isolated plugin configuration and initialize its state using the secured management procedure documented in the full lesson.
  2. Provision one test device account and a least-privilege role. Connect it and verify its own telemetry succeeds.
  3. Attempt cross-device publish and subscription, then send a permitted topic with a mismatched payload identity. Identify whether the broker or application rejects each case.
  4. Revoke/disable the client and observe behavior for both a new connection and an already-connected device.
  5. Restart the broker and confirm the plugin state persists from its dedicated volume.
+

⚠️ What this experiment does not prove

Successful provisioning does not establish secure remote administration unless transport and management access are protected. A plugin role does not replace application schema checks. Revoking an account may not by itself terminate every existing connection; verify the live-client behavior for the pinned broker/plugin.

+

✅ Done when

You can trace provisioning, broker authentication, topic authorization, application validation, and revocation as distinct steps. The normal static lab remains available as a separate configuration.

+

📚 Full failure and revocation experiments: Section 11B source lesson. Primary references: the official Mosquitto Dynamic Security guide and mosquitto_ctrl DynSec manual.

+

Ask me to troubleshoot a denied operation using its client reason and broker log evidence.

+ +
diff --git a/teaching/lessons/0012-vending-workflows.html b/teaching/lessons/0012-vending-workflows.html new file mode 100644 index 0000000..7f6ba9a --- /dev/null +++ b/teaching/lessons/0012-vending-workflows.html @@ -0,0 +1,231 @@ + + + + + Lesson 12 — Vending Workflows + + +
+

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.

+ +
diff --git a/teaching/lessons/0013-persist-reports-command-state.html b/teaching/lessons/0013-persist-reports-command-state.html new file mode 100644 index 0000000..956b184 --- /dev/null +++ b/teaching/lessons/0013-persist-reports-command-state.html @@ -0,0 +1,280 @@ + + + + + Lesson 13 — Persist Reports and Command State + + +
+

Go MQTT Vending Backend · Lesson 13 · 15 minutes

+

Commit business state before acknowledging important work

+

Use PostgreSQL uniqueness and transactions to make report effects durable across duplicate delivery and backend restart.

+
🎯 One skill: choose the durable acceptance boundary🧠 Predict crash recovery🧪 Dedicated lab database only
+

❓ Start with the system story

The backend commits a QoS 1 sales report to PostgreSQL, then crashes before sending PUBACK. What should happen when the broker delivers the report again?

Pause before continuing: identify the durable idempotency key and the order of transaction and acknowledgement.

+

📥 Acknowledge after the policy boundary

+
Receive QoS 1
validate
→
PostgreSQL
dedupe + effect transaction
→
COMMIT
durable outcome
→
Manual Ack
check error
+

With Paho v0.23.0 automatic acknowledgement, a callback error does not request MQTT retry; Paho acknowledges QoS 1 after callback return. Enabling manual acknowledgement changes who calls Ack, but returning an error still does not cause retry by itself. Keep the packet/client acknowledgement handle until the durable policy decision, not just a copied payload in a RAM queue.

+
+

🧠 Check crash recovery: PostgreSQL committed a report, but the backend crashed before PUBACK. What should replay do?

+


+
+

🔁 Let the database arbitrate duplicates

+

Use a unique business key such as (device_id, report_id). In one transaction, insert the report identity, apply its business effect, and store the application outcome needed for duplicate replay. The unique constraint handles two concurrent workers and remains after restart; an in-memory map cannot.

+

If the same key arrives with materially different content, record a conflict instead of silently giving one ID two meanings. MQTT packet IDs are not business IDs. QoS 2 cannot make a PostgreSQL transaction and a physical vending action exactly once.

+

🧭 File overview: internal/persistence/sales.go

+

Create this complete repository file for durable sales ingestion. The caller opens a PostgreSQL *sql.DB using the project's pgx stdlib driver and validates the report with contracts.Decode first. This file owns the transaction boundary: report identity and sale lines commit together.

+

🏗️ Migrate — short summary

Creates a report table with a unique business key and a child line table.

+

💾 SaveReport — short summary

Inserts the report identity, detects duplicate or conflicting content, inserts lines for a new report, and commits once.

+
🧩 Complete file contents
package persistence
+
+import (
+	"context"
+	"crypto/sha256"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+
+	"example.com/mqtt5-learning/internal/contracts"
+)
+
+type ReportOutcome uint8
+
+const (
+	ReportSaved ReportOutcome = iota + 1
+	ReportDuplicate
+)
+
+var ErrReportIDConflict = errors.New("report ID was reused with different content")
+
+type SalesRepository struct {
+	db *sql.DB
+}
+
+func NewSalesRepository(db *sql.DB) (*SalesRepository, error) {
+	if db == nil {
+		return nil, fmt.Errorf("database connection is required")
+	}
+	return &SalesRepository{db: db}, nil
+}
+
+func (r *SalesRepository) Migrate(ctx context.Context) error {
+	statements := []string{
+`CREATE TABLE IF NOT EXISTS sales_reports (
+    device_id TEXT NOT NULL,
+    report_id TEXT NOT NULL,
+    payload_hash BYTEA NOT NULL,
+    occurred_at TIMESTAMPTZ NOT NULL,
+    saved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (device_id, report_id)
+)`,
+`CREATE TABLE IF NOT EXISTS sales_report_lines (
+    device_id TEXT NOT NULL,
+    report_id TEXT NOT NULL,
+    line_number INTEGER NOT NULL,
+    sku TEXT NOT NULL,
+    quantity BIGINT NOT NULL CHECK (quantity > 0),
+    amount_minor BIGINT NOT NULL CHECK (amount_minor >= 0),
+    PRIMARY KEY (device_id, report_id, line_number),
+    FOREIGN KEY (device_id, report_id)
+        REFERENCES sales_reports (device_id, report_id) ON DELETE CASCADE
+	)`,
+	}
+	for _, statement := range statements {
+		if _, err := r.db.ExecContext(ctx, statement); err != nil {
+			return fmt.Errorf("create sales schema: %w", err)
+		}
+	}
+	return nil
+}
+
+func (r *SalesRepository) SaveReport(ctx context.Context, report contracts.SalesReport) (ReportOutcome, error) {
+	if report.DeviceID == "" || report.ReportID == "" || len(report.Items) == 0 {
+		return 0, fmt.Errorf("validated report identity and at least one item are required")
+	}
+	occurredAt, err := time.Parse(time.RFC3339, report.OccurredAt)
+	if err != nil {
+		return 0, fmt.Errorf("parse report time: %w", err)
+	}
+	encoded, err := json.Marshal(report)
+	if err != nil {
+		return 0, fmt.Errorf("encode report for deduplication: %w", err)
+	}
+	hash := sha256.Sum256(encoded)
+	tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
+	if err != nil {
+		return 0, fmt.Errorf("begin report transaction: %w", err)
+	}
+	defer func() { _ = tx.Rollback() }() // No effect after a successful commit.
+
+	result, err := tx.ExecContext(ctx, `
+INSERT INTO sales_reports (device_id, report_id, payload_hash, occurred_at)
+VALUES ($1, $2, $3, $4)
+ON CONFLICT (device_id, report_id) DO NOTHING`,
+		report.DeviceID, report.ReportID, hash[:], occurredAt)
+	if err != nil {
+		return 0, fmt.Errorf("insert report identity: %w", err)
+	}
+	inserted, err := result.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("inspect report insert: %w", err)
+	}
+	if inserted == 0 {
+		var storedHash []byte
+		if err := tx.QueryRowContext(ctx, `
+SELECT payload_hash FROM sales_reports WHERE device_id=$1 AND report_id=$2`,
+			report.DeviceID, report.ReportID).Scan(&storedHash); err != nil {
+			return 0, fmt.Errorf("load duplicate report: %w", err)
+		}
+		if !equalBytes(storedHash, hash[:]) {
+			return 0, ErrReportIDConflict
+		}
+		if err := tx.Commit(); err != nil {
+			return 0, fmt.Errorf("commit duplicate observation: %w", err)
+		}
+		return ReportDuplicate, nil
+	}
+	for i, line := range report.Items {
+		if _, err := tx.ExecContext(ctx, `
+INSERT INTO sales_report_lines
+    (device_id, report_id, line_number, sku, quantity, amount_minor)
+VALUES ($1, $2, $3, $4, $5, $6)`,
+			report.DeviceID, report.ReportID, i, line.SKU, line.Quantity, line.AmountMinor); err != nil {
+			return 0, fmt.Errorf("insert sales line %d: %w", i, err)
+		}
+	}
+	if err := tx.Commit(); err != nil {
+		return 0, fmt.Errorf("commit sales report: %w", err)
+	}
+	return ReportSaved, nil
+}
+
+func equalBytes(left, right []byte) bool {
+	if len(left) != len(right) {
+		return false
+	}
+	var difference byte
+	for i := range left {
+		difference |= left[i] ^ right[i]
+	}
+	return difference == 0
+}
+
+

🔎 Details: duplicate and transaction behavior

The unique constraint handles concurrent transactions for one device/report pair. The winner inserts lines in the same transaction as the report row. A second identical report reads the stored digest and returns ReportDuplicate; different content under the same key returns ErrReportIDConflict. The deferred rollback is harmless after commit and cleans up early returns. Do not Ack the MQTT packet until this method returns a committed saved/duplicate outcome. This repository does not send the application response; publication intent belongs in an outbox when crash-safe receipts are required.

+

🧭 File overview: internal/persistence/commands.go

+

Command state also needs durable storage. Create this complete file alongside sales.go. It stores validated device results under the stable command ID and allows a late result to resolve an earlier unknown outcome without losing the separate broker-ack field.

+

🏗️ Migrate — short summary

Creates the command table and a unique result table.

+

📩 ApplyResult — short summary

Inserts one result, accepts identical replays, detects conflicting IDs, and updates command status transactionally.

+
📄 internal/persistence/commands.go — complete file
package persistence
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+
+	"example.com/mqtt5-learning/internal/contracts"
+)
+
+var ErrCommandResultConflict = errors.New("command ID has a conflicting result")
+
+type CommandRepository struct { db *sql.DB }
+
+func NewCommandRepository(db *sql.DB) (*CommandRepository, error) {
+	if db == nil { return nil, fmt.Errorf("database connection is required") }
+	return &CommandRepository{db: db}, nil
+}
+
+func (r *CommandRepository) Migrate(ctx context.Context) error {
+	statements := []string{
+		`CREATE TABLE IF NOT EXISTS device_commands (
+			command_id TEXT PRIMARY KEY, device_id TEXT NOT NULL, state TEXT NOT NULL,
+			broker_acked BOOLEAN NOT NULL DEFAULT FALSE,
+			deadline TIMESTAMPTZ,
+			created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
+		`CREATE TABLE IF NOT EXISTS command_results (
+			command_id TEXT NOT NULL REFERENCES device_commands(command_id),
+			device_id TEXT NOT NULL, message_id TEXT NOT NULL, status TEXT NOT NULL,
+			detail TEXT NOT NULL DEFAULT '', occurred_at TIMESTAMPTZ NOT NULL,
+			PRIMARY KEY(command_id,message_id), UNIQUE(device_id,message_id))`,
+	}
+	for _, statement := range statements {
+		if _, err := r.db.ExecContext(ctx, statement); err != nil {
+			return fmt.Errorf("create command schema: %w", err)
+		}
+	}
+	return nil
+}
+
+func (r *CommandRepository) Create(ctx context.Context, commandID, deviceID string) error {
+	if commandID == "" || deviceID == "" { return fmt.Errorf("command ID and device ID are required") }
+	_, err := r.db.ExecContext(ctx, `INSERT INTO device_commands(command_id,device_id,state)
+VALUES ($1,$2,'pending')`, commandID, deviceID)
+	if err != nil { return fmt.Errorf("create command: %w", err) }
+	return nil
+}
+
+func (r *CommandRepository) MarkBrokerAck(ctx context.Context, commandID string) error {
+	result, err := r.db.ExecContext(ctx, `UPDATE device_commands SET broker_acked=TRUE,updated_at=now()
+WHERE command_id=$1`, commandID)
+	if err != nil { return fmt.Errorf("record broker acknowledgement: %w", err) }
+	rows, err := result.RowsAffected()
+	if err != nil { return fmt.Errorf("inspect command update: %w", err) }
+	if rows != 1 { return fmt.Errorf("command %q not found", commandID) }
+	return nil
+}
+
+func (r *CommandRepository) ApplyResult(ctx context.Context, result contracts.CommandResult) error {
+	if result.CommandID == "" || result.DeviceID == "" || result.MessageID == "" {
+		return fmt.Errorf("validated result identity is required")
+	}
+	occurredAt, err := time.Parse(time.RFC3339, result.OccurredAt)
+	if err != nil { return fmt.Errorf("parse result time: %w", err) }
+	tx, err := r.db.BeginTx(ctx, nil)
+	if err != nil { return fmt.Errorf("begin result transaction: %w", err) }
+	defer func() { _ = tx.Rollback() }()
+	insert, err := tx.ExecContext(ctx, `INSERT INTO command_results
+(command_id,device_id,message_id,status,detail,occurred_at)
+VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (command_id,message_id) DO NOTHING`,
+		result.CommandID,result.DeviceID,result.MessageID,result.Status,result.Detail,occurredAt)
+	if err != nil { return fmt.Errorf("insert command result: %w", err) }
+	rows, err := insert.RowsAffected()
+	if err != nil { return fmt.Errorf("inspect result insert: %w", err) }
+	if rows == 0 {
+		var oldDevice, oldMessage, oldStatus, oldDetail string
+		if err := tx.QueryRowContext(ctx, `SELECT device_id,message_id,status,detail
+FROM command_results WHERE command_id=$1 AND message_id=$2`, result.CommandID,result.MessageID).Scan(&oldDevice,&oldMessage,&oldStatus,&oldDetail); err != nil {
+			return fmt.Errorf("load prior command result: %w", err)
+		}
+		if oldDevice != result.DeviceID || oldMessage != result.MessageID || oldStatus != result.Status || oldDetail != result.Detail {
+			return ErrCommandResultConflict
+		}
+		return tx.Commit() // Identical replay: keep the prior terminal state.
+	}
+	updated, err := tx.ExecContext(ctx, `UPDATE device_commands SET state=$2,updated_at=now()
+WHERE command_id=$1 AND device_id=$3 AND state IN ('pending','accepted','unknown')`,
+		result.CommandID,result.Status,result.DeviceID)
+	if err != nil { return fmt.Errorf("update command state: %w", err) }
+	changed, err := updated.RowsAffected()
+	if err != nil { return fmt.Errorf("inspect command state update: %w", err) }
+	if changed != 1 { return fmt.Errorf("command missing, device mismatch, or terminal state conflict") }
+	if err := tx.Commit(); err != nil { return fmt.Errorf("commit command result: %w", err) }
+	return nil
+}
+
+

🔎 Details: legal state transitions

The state IN ('pending','accepted','unknown') guard prevents a new result from overwriting an already terminal state such as completed or rejected. The result row and command-state update share a transaction. broker_acked remains a separate transport fact. Before using this example, ensure the command row exists and the result's topic/body identity was validated in the ingestion service.

+

🧹 Invalid and transient failures need different paths

+
OutcomeDurable actionMQTT decision
Valid new reportCommit identity and effectAck after commit
Valid duplicateLoad prior result; no repeated effectAck after recognizing it
Permanently invalidCommit bounded quarantine/rejection metadataAck after disposition commits
Transient database failureDo not claim durable acceptanceDo not Ack; invoke explicit recovery/backoff
+

A poison message must not occupy a Receive Maximum slot forever. Record safe metadata such as topic, reason category, and receipt time; avoid secrets and arbitrary payload dumps. A transient database outage is not permanent invalid input.

+

🧪 Crash-point experiments

+
  1. Send the same report twice sequentially and concurrently. Expect one durable business effect.
  2. Force a transaction rollback and verify neither dedupe identity nor business effect commits alone.
  3. Commit, stop the process before manual Ack, reconnect with an eligible broker session, and observe a safe duplicate.
  4. Send a permanently invalid report and confirm a bounded rejection record commits before Ack.
  5. Delay acknowledgements to the configured Receive Maximum and observe delivery pressure. Remember this protocol limit does not cap your Go worker queue.
  6. Record the gap between database commit and publishing an application response; Lesson 14 closes that gap with an outbox.
+

🧱 Keep durable and temporary state distinct

Persist reports, deduplication keys, command state/results, and any publication intent needed after restart. Keep live connections, worker queues, in-flight Paho packet handles, and process contexts in memory. On restart, recover from durable rows and eligible MQTT sessions; a dead callback cannot resume.

Evidence boundary: database COMMIT establishes the backend's durable acceptance policy. PUBACK completes the MQTT protocol delivery. A separate application receipt tells the device the backend outcome, and it can still be lost unless its publication intent is persisted.

+

✅ Done when

After a backend restart, replay does not double-count a report; a database outage is not acknowledged as durable success; invalid data has a durable disposition; and you can explain why manual Ack alone is not permanent broker storage.

+

Run only against the dedicated lab PostgreSQL service and synthetic data. The full source lesson includes the project-specific Docker commands and Paho acknowledgement references.

+
📋 Short self-check answers
  1. Unique stable business key plus one transaction for identity/effect/outcome.
  2. Commit then Ack; after crash-before-Ack, recognize duplicate and avoid repeating the effect.
  3. Callback error alone does not request retry; recovery is an application policy.
  4. Manual Ack is not a durable application queue and depends on session/broker behavior.
+

📚 Failure experiments: Lesson 13 source lesson. Primary references: Go database/sql, PostgreSQL constraints, and the OASIS MQTT 5.0 Standard.

+

Ask me to reason through the exact crash point you observed before changing the acknowledgement policy.

+ +
diff --git a/teaching/lessons/0014-outbox-failure-gaps.html b/teaching/lessons/0014-outbox-failure-gaps.html new file mode 100644 index 0000000..16bd42d --- /dev/null +++ b/teaching/lessons/0014-outbox-failure-gaps.html @@ -0,0 +1,149 @@ + + + + Lesson 14 — Close Database and MQTT Failure Gaps + +
+

Go MQTT Vending Backend · Lesson 14 · 15 minutes

+

Keep accepted publish intent recoverable

+

Use a transactional outbox to bridge the database and MQTT broker while keeping retries, duplicates, and uncertain device outcomes visible.

+
🎯 One skill: reason about cross-system failure🧠 Predict duplicate publication🧪 Synthetic commands only
+

❓ Start with the system story

The API commits a ping command to PostgreSQL, then crashes before publishing it. What durable record lets another process resume the accepted work?

Name which values must commit together and which process retries them.

+

🧾 Commit business intent and publish intent together

+
HTTP API
validate + authorize
→
One DB transaction
command + outbox row
→
Publisher worker
bounded claim + retry
→
MQTT + device
stable ID + dedupe
+

The transaction closes the gap where a command is durably accepted but the intent to publish is lost. A worker claims rows briefly, publishes outside the DB transaction, then records the attempt. Do not hold a database transaction open while waiting on a network operation.

+

🧠 Check the guarantee: What makes accepted work publishable after an API process crashes before MQTT publish?




+

🪟 The outbox still permits duplicates

+

If the broker accepts a PUBLISH and the backend crashes before marking its outbox row sent, the worker may publish it again after restart. Keep one command ID across retries. The simulator stores the prior effect/result by command ID and returns that outcome on a duplicate. A timeout after possible publication is an unknown device outcome, not proof that it did nothing.

+
Crash pointWhat remainsRecovery
Before transaction commitNo accepted command/outboxCaller may retry with the same HTTP idempotency key
After commit, before publishPending outbox rowWorker publishes after restart
After publish, before sent markerRow may remain pendingRepublish same command ID; device deduplicates
After device effect, before resultDevice may have acted; backend lacks resultExpose unknown/pending and reconcile by stable ID
+

🧭 File overview: internal/outbox/store.go

+

Create this complete PostgreSQL repository file for the outbox chapter. The HTTP API calls Enqueue in its acceptance path; a bounded worker uses ClaimOne and records the PUBACK outcome with MarkPublished. No database transaction stays open during the network publish.

+

🧾 Enqueue — short summary

Commits the command state and serialized publication intent atomically under one command ID.

+

🔒 ClaimOne — short summary

Uses a short transaction and FOR UPDATE SKIP LOCKED to lease one eligible row for one worker.

+

📬 MarkPublished — short summary

Records the broker acknowledgement as transport evidence and marks only that command's outbox row sent.

+
🧩 Complete file contents
package outbox
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+)
+
+var ErrNoWork = errors.New("no eligible outbox row")
+
+type Command struct {
+	ID        string
+	DeviceID  string
+	Topic     string
+	Payload   []byte
+	Deadline  time.Time
+}
+
+type Item struct {
+	CommandID string
+	DeviceID  string
+	Topic     string
+	Payload   []byte
+	Deadline  time.Time
+	Attempt   int
+}
+
+type Store struct { db *sql.DB }
+
+func NewStore(db *sql.DB) (*Store, error) {
+	if db == nil { return nil, fmt.Errorf("database connection is required") }
+	return &Store{db: db}, nil
+}
+
+func (s *Store) Migrate(ctx context.Context) error {
+	statements := []string{
+		`CREATE TABLE IF NOT EXISTS device_commands (
+			command_id TEXT PRIMARY KEY, device_id TEXT NOT NULL, state TEXT NOT NULL,
+			deadline TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
+		`CREATE TABLE IF NOT EXISTS command_outbox (
+			command_id TEXT PRIMARY KEY REFERENCES device_commands(command_id),
+			topic TEXT NOT NULL, payload BYTEA NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
+			available_at TIMESTAMPTZ NOT NULL DEFAULT now(), lease_until TIMESTAMPTZ,
+			published_at TIMESTAMPTZ, last_reason SMALLINT)`,
+	}
+	for _, statement := range statements {
+		if _, err := s.db.ExecContext(ctx, statement); err != nil {
+			return fmt.Errorf("create outbox schema: %w", err)
+		}
+	}
+	return nil
+}
+
+func (s *Store) Enqueue(ctx context.Context, command Command) error {
+	if command.ID == "" || command.DeviceID == "" || command.Topic == "" || len(command.Payload) == 0 {
+		return fmt.Errorf("command ID, device, topic, and payload are required")
+	}
+	if !command.Deadline.After(time.Now()) {
+		return fmt.Errorf("command deadline must be in the future")
+	}
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil { return fmt.Errorf("begin command transaction: %w", err) }
+	defer func() { _ = tx.Rollback() }()
+	if _, err := tx.ExecContext(ctx, `INSERT INTO device_commands(command_id,device_id,state,deadline)
+VALUES ($1,$2,'pending',$3)`, command.ID, command.DeviceID, command.Deadline); err != nil {
+		return fmt.Errorf("insert command: %w", err)
+	}
+	if _, err := tx.ExecContext(ctx, `INSERT INTO command_outbox(command_id,topic,payload)
+VALUES ($1,$2,$3)`, command.ID, command.Topic, command.Payload); err != nil {
+		return fmt.Errorf("insert outbox intent: %w", err)
+	}
+	if err := tx.Commit(); err != nil { return fmt.Errorf("commit command and outbox: %w", err) }
+	return nil
+}
+
+func (s *Store) ClaimOne(ctx context.Context, lease time.Duration, now time.Time) (Item, error) {
+	if lease <= 0 { return Item{}, fmt.Errorf("lease must be positive") }
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil { return Item{}, fmt.Errorf("begin outbox claim: %w", err) }
+	defer func() { _ = tx.Rollback() }()
+	var item Item
+	err = tx.QueryRowContext(ctx, `SELECT o.command_id,c.device_id,o.topic,o.payload,c.deadline,o.attempts
+FROM command_outbox o JOIN device_commands c USING (command_id)
+WHERE o.published_at IS NULL AND o.available_at <= $1
+  AND (o.lease_until IS NULL OR o.lease_until <= $1)
+  AND c.deadline > $1
+ORDER BY o.available_at,o.command_id
+LIMIT 1 FOR UPDATE OF o SKIP LOCKED`, now).Scan(
+		&item.CommandID, &item.DeviceID, &item.Topic, &item.Payload, &item.Deadline, &item.Attempt)
+	if errors.Is(err, sql.ErrNoRows) { return Item{}, ErrNoWork }
+	if err != nil { return Item{}, fmt.Errorf("select outbox work: %w", err) }
+	item.Payload = append([]byte(nil), item.Payload...)
+	if _, err := tx.ExecContext(ctx, `UPDATE command_outbox
+SET lease_until=$2, attempts=attempts+1 WHERE command_id=$1`, item.CommandID, now.Add(lease)); err != nil {
+		return Item{}, fmt.Errorf("lease outbox work: %w", err)
+	}
+	if err := tx.Commit(); err != nil { return Item{}, fmt.Errorf("commit outbox lease: %w", err) }
+	item.Attempt++
+	return item, nil
+}
+
+func (s *Store) MarkPublished(ctx context.Context, commandID string, reason byte, at time.Time) error {
+	result, err := s.db.ExecContext(ctx, `UPDATE command_outbox SET published_at=$2,
+lease_until=NULL,last_reason=$3 WHERE command_id=$1 AND published_at IS NULL`, commandID, at, reason)
+	if err != nil { return fmt.Errorf("mark outbox published: %w", err) }
+	rows, err := result.RowsAffected()
+	if err != nil { return fmt.Errorf("inspect outbox update: %w", err) }
+	if rows != 1 { return fmt.Errorf("outbox command %q was not pending", commandID) }
+	return nil
+}
+
+

🔎 Details: lease and retry policy

The lease stops two active workers from claiming the same row at once. A crashed worker leaves a lease that expires; a later worker can claim and republish. A DB transaction makes intent durable, but it cannot atomically include the MQTT PUBLISH. MarkPublished therefore follows the PUBLISH and can race a crash; the device must deduplicate. Production code should add bounded exponential backoff, a retryable/permanent error classification, and a separate terminal outcome for expired rows rather than retrying forever.

+

📚 Keep three queues separate

+

Transactional outbox

Application DB owns durable business intent and retry status. It does not prove broker delivery or device completion.

Paho outgoing queue

Client library owns transport queueing under its configured semantics. It is not atomic with the business transaction.

MQTT session state

Client/broker own protocol session continuity and eligible queued deliveries. It does not store business intent or dedupe effects.

+

⏳ Bound retries and stale work

+

Set a retry window with backoff and jitter, bound concurrent claims and publishes, and measure pending row count and oldest age. Check the application deadline before sending; use MQTT Message Expiry for broker-queued lifetime; have the device check its deadline before execution. Park expired or permanent failures and preserve unknown outcomes.

+

🧪 Inject each failure window

+
  1. Commit an outbox row while the publisher is stopped; restart it and confirm the row is attempted.
  2. Stop the worker after publish but before marking sent. Restart and confirm duplicate publication keeps the command ID and causes one simulated effect.
  3. Drop the device response after its simulated effect. Verify status does not claim definite failure.
  4. Expire a row before publish and verify it is not sent.
  5. Restart the broker and compare the pending application outbox state with broker session recovery.
+

✅ Done when

Every crash point has a recovery policy; duplicate publication creates one simulated effect; expiry blocks stale work; status separates PUBACK from device completion.

Evidence to record: prediction, injected crash point, DB rows, MQTT evidence, simulator effect count, final command state, and cleanup.

+
📋 Short self-check answers
  1. Commit the command and outbox intent atomically.
  2. The worker can crash after PUBACK but before the sent marker; same-ID retry may duplicate.
  3. Paho queue admission does not prove broker delivery or business completion.
  4. Report unknown when the device may have acted but the application response is missing.
+

📚 Full crash experiment plan: Lesson 14 source lesson. Primary references: PostgreSQL locking clauses and OASIS MQTT 5.0 Standard.

+

Ask me to trace a crash window against the durable row and MQTT packet evidence.

+ +
diff --git a/teaching/lessons/0015-process-boundaries.html b/teaching/lessons/0015-process-boundaries.html new file mode 100644 index 0000000..6df6921 --- /dev/null +++ b/teaching/lessons/0015-process-boundaries.html @@ -0,0 +1,71 @@ + + + + Lesson 15 — Split API and Ingestion Processes + +
+

Go MQTT Vending Backend · Lesson 15 · 12 minutes

+

Split processes around durable ownership

+

Let an HTTP API accept and query durable commands while an independent MQTT ingestion process stores device results.

+
🎯 One skill: design across process boundaries🧠 Predict independent restarts🧪 Local processes
+

❓ Start with the system story

The API process creates a command. A separate ingestion process receives the device result. Which process owns the command's pending waiter, and how can HTTP status remain correct if the API restarts?

Pause: a Go map shared only in one process cannot be the answer.

+

🗂️ Put shared truth in PostgreSQL

+
HTTP API
authorize + write command
→
PostgreSQL
command/outbox/result state
↔
Ingestion process
subscribe + validate + persist
+

The API serves HTTP, checks user/device permission, creates durable command state and outbox intent, and reads status. Ingestion owns MQTT subscriptions and result processing. Both use the database as their shared source of truth. A pending-request map can still optimize local waiting, but it cannot be required for correctness across processes.

+

🧠 Check the ownership: How should API status reflect a result received in another process?




+

🧩 Give each process its own lifecycle

+

Use separate executables, signal contexts, health endpoints, and MQTT Client IDs. The ingestion process owns the response/telemetry subscriptions; the API or a dedicated publisher worker owns command publication. State that ownership explicitly so two processes do not accidentally compete for a device command subscription.

+

Readiness should mean the process can perform its assigned role: API readiness can include database availability and HTTP dependencies; ingestion readiness can include database availability and restored required MQTT subscriptions. Avoid arbitrary startup sleeps. Expose the dependency that is not ready.

+

📄 Complete artifact: docs/process-ownership.md

+

This is the complete ownership contract to create before extracting executables. Fill every bracketed value from the actual deployment; the template is intentionally not a pretend Go implementation. It makes the process split reviewable and catches duplicate MQTT ownership before deployment.

+
🧭 Full file contents
# Process ownership
+
+## Runtime identities
+
+| Process | Executable | MQTT Client ID | MQTT responsibilities | HTTP responsibilities |
+| --- | --- | --- | --- | --- |
+| API | `cmd/api` | `[none or explicit publisher ID]` | `[publish only if assigned; never subscribe to device results]` | Authorize callers, create durable commands, query command state, serve `/livez` and `/readyz` |
+| Ingestion | `cmd/ingest` | `[unique stable ingestion ID]` | Own response and report subscriptions; validate and persist inbound messages | Serve `/livez` and `/readyz` if this role exposes health over HTTP |
+| Outbox worker | `[same API process or separate executable]` | `[unique publisher ID when separate]` | Claim durable publish intent, publish, record broker outcome | No public command-creation endpoint |
+
+## Durable state contract
+
+- PostgreSQL owns accepted command state, the outbox, deduplication keys, and received results.
+- A process-local waiter or cache may reduce latency; it never decides correctness.
+- Every retry reuses the original command ID and idempotency key.
+- A broker acknowledgment records a transport event. Device acceptance, completion, and physical effect require separate evidence.
+- `[Describe migration ownership and how both versions remain compatible during rollout.]`
+
+## Readiness and lifecycle
+
+| Role | Ready when | Shutdown sequence | Recoverable work |
+| --- | --- | --- | --- |
+| API | Database is reachable and HTTP dependencies are initialized | Stop accepting requests, finish bounded in-flight work, close listeners | Commands and outbox rows committed before shutdown |
+| Ingestion | Database is reachable and required subscriptions have successful SUBACKs | Stop admission, finish or safely abandon bounded handlers, close MQTT | Unacknowledged QoS 1 deliveries and dedupe records, according to configured ack policy |
+| Outbox worker | Database and assigned MQTT connection are ready | Stop new claims, finish bounded publish, release/expire leases | Unpublished or expired leases are reclaimed after restart |
+
+## Deployment checks
+
+1. Confirm each simultaneously running MQTT process has a distinct Client ID.
+2. Confirm only the ingestion owner subscribes to each response/report filter.
+3. Confirm API writes command and outbox intent in one database transaction.
+4. Stop API while ingestion stores a result; restart API and query the same command ID.
+5. Stop ingestion while API remains live; confirm API can accept/query work and readiness/status reports delayed result processing honestly.
+6. Restart either process with an outbox lease and an in-flight QoS 1 delivery; inspect durable state and duplicate handling.
+7. Record observed readiness, recovery, and shutdown evidence: `[commands, timestamps, logs, and database query results]`.
+
+## Open questions
+
+- Device completion evidence: `[authoritative contract or owner]`
+- Broker session persistence and recovery target: `[configuration or owner]`
+- Credential ownership and rotation: `[owner and procedure]`
+- Maximum shutdown drain period: `[duration and consequence when exceeded]`
+
+

🧪 Restart either process

+
  1. Start API and ingestion independently and verify they use distinct Client IDs and assigned responsibilities.
  2. Create a harmless ping through HTTP; inspect the durable command and outbox records.
  3. Stop/restart API while ingestion receives the response. Query status after API restart.
  4. Stop/restart ingestion while API remains available. Confirm the API can create and query commands while clearly reporting delayed result processing.
  5. Test shutdown with pending outbox or MQTT work; confirm state remains recoverable and subscriptions restore without duplicate local handler registration.
+

⚠️ What to avoid

Do not make correctness depend on a shared in-memory waiter map. Do not assign the same stable MQTT Client ID to two simultaneous processes unless they are deliberately replacing one another: they can disconnect each other and alter session ownership. Notifications and caches may accelerate reads, but PostgreSQL remains the correctness baseline.

+

✅ Done when

HTTP status remains accurate when ingestion receives the response in a different process. Either process can restart independently without losing durable command tracking. Your run notes state client IDs, subscription ownership, readiness conditions, and recovery behavior.

+

📚 Section checklist and process boundaries: COURSE.md — Section 15. Primary references: Go signal lifecycle and PostgreSQL locking.

+

Ask me to review your ownership table against a proposed process layout before you extract the executables.

+ +
diff --git a/teaching/lessons/0016-mqtt5-controls.html b/teaching/lessons/0016-mqtt5-controls.html new file mode 100644 index 0000000..101a489 --- /dev/null +++ b/teaching/lessons/0016-mqtt5-controls.html @@ -0,0 +1,100 @@ + + + + Lesson 16 — Advanced MQTT 5 Controls + +
+

Go MQTT Vending Backend · Lesson 16 · 15 minutes

+

Choose MQTT controls from measured behavior

+

Experiment with subscription options, shared consumers, flow limits, packet sizes, aliases, and reason codes without confusing protocol controls with application policy.

+
🎯 One skill: measure before adopting a feature🧠 Predict packet effects🧪 Version-pinned lab
+

❓ Start with the system story

Two ingestion processes share a subscription. A command response reaches the instance that did not create the original process-local waiter. Which state design still lets that instance process the result correctly?

Answer with the shared durable source of truth, then explore the protocol controls.

+

📬 Subscription options shape delivery

+
OptionChangesDoes not do
No LocalSuppresses same-client-originated publications for that subscriptionAuthorize a topic or filter a device identity
Retain As PublishedPreserves incoming RETAIN bit on forwarded live trafficChoose whether broker stores retained state
Retain HandlingControls retained replay when subscription is establishedBlock future live retained publications
UNSUBSCRIBERemoves the broker subscription after UNSUBACKErase retained state or unregister a local Go handler
+

Change one option at a time with a retained status topic. Record whether the publication was a retained replay or live delivery and inspect the packet flags. Never set No Local as a substitute for ACLs.

+

🧠 Check scaling behavior: What makes a shared-subscription response safe when it reaches a different ingestion instance?




+

⚖️ Shared subscriptions and limits

+

Run two ingestion instances in one shared group. Observe distribution and what happens when one stops. Do not assume per-device ordering or affinity; both workers must use durable idempotency. MQTT Receive Maximum limits unacknowledged QoS 1/2 protocol exchanges in a direction. It is not your Go queue capacity, and it does not limit QoS 0 in the same way.

+

Advertised Maximum Packet Size limits packet size in the direction advertised. Test client-to-broker and broker-to-client separately. Compare protocol inflight count with application queue depth; they can differ sharply if callbacks acknowledge before queued work is durable.

+

🏷️ Aliases, reason codes, and support matrix

+

Topic Alias mappings are scoped to a network connection. A reconnect needs a new mapping even if the MQTT session resumes. Confirm the pinned Paho and Mosquitto support and lifecycle reset behavior before treating aliases as an optimization; a TopicAlias field existing in a type is not evidence that a full flow works.

+

Interpret reason codes in their packet context. PUBACK “No matching subscribers” is not a device response. A SUBACK result applies to its filter. A context deadline means the local wait ended and may leave business outcome uncertain. Use bounded reconnect backoff with jitter for transient failures; repair authentication/configuration errors instead of retrying faster.

+

🧭 File overview: internal/mqtt/controls.go

+

Create this complete helper file to build MQTT 5 CONNECT and SUBSCRIBE options without mixing broker protocol limits with local queue capacity. The caller still inspects actual CONNACK/SUBACK results and checks version-matched Paho support.

+

🧰 NewConnectProperties — short summary

Builds validated client Receive Maximum and Maximum Packet Size properties.

+

📬 NewSubscription — short summary

Builds one subscription and rejects invalid QoS, Retain Handling, and No Local on a shared filter.

+

🔎 CapabilitiesFromConnack — short summary

Copies optional server capability values safely, preserving nil as “not advertised.”

+
📄 Complete file contents
package mqtt
+
+import (
+	"fmt"
+	"strings"
+
+	"example.com/mqtt5-learning/internal/contracts"
+	"github.com/eclipse/paho.golang/paho"
+)
+
+type ServerCapabilities struct {
+	SessionPresent bool
+	MaximumQoS *byte
+	ReceiveMaximum *uint16
+	MaximumPacketSize *uint32
+	TopicAliasMaximum *uint16
+	SharedSubscriptions bool
+	RetainedMessages bool
+}
+
+func NewConnectProperties(receiveMaximum uint16, maximumPacketSize uint32) (*paho.ConnectProperties, error) {
+	if receiveMaximum == 0 { return nil, fmt.Errorf("receive maximum must be at least one") }
+	if maximumPacketSize == 0 { return nil, fmt.Errorf("maximum packet size must be at least one byte") }
+	packetLimit := maximumPacketSize
+	flightLimit := receiveMaximum
+	return &paho.ConnectProperties{ReceiveMaximum: &flightLimit, MaximumPacketSize: &packetLimit}, nil
+}
+
+func NewSubscription(filter contracts.TopicFilter, qos, retainHandling byte, noLocal, retainAsPublished bool) (paho.SubscribeOptions, error) {
+	value := filter.String()
+	if value == "" { return paho.SubscribeOptions{}, fmt.Errorf("subscription filter is required") }
+	if qos > 2 { return paho.SubscribeOptions{}, fmt.Errorf("QoS must be 0, 1, or 2") }
+	if retainHandling > 2 { return paho.SubscribeOptions{}, fmt.Errorf("Retain Handling must be 0, 1, or 2") }
+	if noLocal && strings.HasPrefix(value, "$share/") {
+		return paho.SubscribeOptions{}, fmt.Errorf("No Local cannot be used with a shared subscription")
+	}
+	return paho.SubscribeOptions{
+		Topic: value, QoS: qos, RetainHandling: retainHandling,
+		NoLocal: noLocal, RetainAsPublished: retainAsPublished,
+	}, nil
+}
+
+func CapabilitiesFromConnack(ack *paho.Connack) ServerCapabilities {
+	if ack == nil { return ServerCapabilities{} }
+	result := ServerCapabilities{SessionPresent: ack.SessionPresent}
+	if ack.Properties == nil { return result }
+	result.MaximumQoS = copyByte(ack.Properties.MaximumQoS)
+	result.ReceiveMaximum = copyUint16(ack.Properties.ReceiveMaximum)
+	result.MaximumPacketSize = copyUint32(ack.Properties.MaximumPacketSize)
+	result.TopicAliasMaximum = copyUint16(ack.Properties.TopicAliasMaximum)
+	result.SharedSubscriptions = ack.Properties.SharedSubAvailable
+	result.RetainedMessages = ack.Properties.RetainAvailable
+	return result
+}
+
+func copyByte(value *byte) *byte {
+	if value == nil { return nil }; copy := *value; return &copy
+}
+func copyUint16(value *uint16) *uint16 {
+	if value == nil { return nil }; copy := *value; return &copy
+}
+func copyUint32(value *uint32) *uint32 {
+	if value == nil { return nil }; copy := *value; return &copy
+}
+
+

🔎 Details: limits and capability defaults

The helper does not resize your application queue or guarantee that the broker supports a requested behavior. The server's absent optional numeric properties stay nil; callers must apply the MQTT-defined defaults for the relevant feature. This small struct records the fields needed by the experiment and does not claim to capture every negotiated capability. Topic Alias state must be rebuilt after a new network connection.

+

🧪 Build a feature decision matrix

+
  1. Record Paho and broker versions and the exact option/API field.
  2. For each experiment record purpose, packet and direction, prediction, actual observation, and whether you would use the feature.
  3. Test No Local, Retain As Published, and each Retain Handling value with stored and live messages.
  4. Run two shared consumers; stop one during traffic and inspect distribution, duplicates, and result processing.
  5. Test Receive Maximum and packet-size negotiation in both directions. Measure protocol inflight and application queue separately.
  6. Review Topic Alias and reason-code paths against the pinned source; mark reading-only items as not experimentally verified.
+

✅ Done when

Your matrix distinguishes MQTT specification, broker, client-library, and application behavior. Every feature has evidence or is marked unverified; no optimization is selected just because the API exposes a field.

+
📋 Short self-check answers
  1. Retain Handling controls replay at subscribe time; Retain As Published controls a flag on forwarded live traffic.
  2. Shared subscriptions distribute work and do not guarantee affinity; durable state lets any instance process safely.
  3. Receive Maximum limits in-flight QoS 1/2 packets, not an application's total queue.
  4. Aliases need to be re-established after reconnect.
+

📚 Detailed experiments and Paho v0.23.0 source pointers: Lesson 16 source lesson. Primary references: OASIS MQTT 5.0 Standard and Paho v0.23.0 API.

+

Ask me to map an observed control or reason code to its packet and owning layer.

+ +
diff --git a/teaching/lessons/0017-operations-diagnostics.html b/teaching/lessons/0017-operations-diagnostics.html new file mode 100644 index 0000000..3972dbc --- /dev/null +++ b/teaching/lessons/0017-operations-diagnostics.html @@ -0,0 +1,83 @@ + + + + Lesson 17 — Operate and Diagnose the Service + +
+

Go MQTT Vending Backend · Lesson 17 · 15 minutes

+

Diagnose the failing boundary with evidence

+

Make each command traceable across HTTP, Go processes, MQTT, broker, device simulator, and PostgreSQL.

+
🎯 One skill: turn symptoms into a diagnostic path🧠 Predict likely fault layer🧪 Repeatable failure matrix
+

❓ Start with the system story

An API returns 202, the backend records PUBACK, but no device result appears. What evidence do you inspect next, and what can you not conclude yet?

Trace the same command ID through each owner before labeling the device failed.

+

🔎 Put identifiers in logs, not metric labels

+

Use structured logs with stable command/report IDs, process identity, message kind, and safe error categories. Redact credentials and avoid arbitrary payload dumps. Put high-cardinality IDs in logs/traces where appropriate, not unrestricted metric labels that create a new time series per device or command.

+

Metrics worth watching

Reconnects, rejected messages, queue depth/capacity, duplicate outcomes, publish and response latency, timeouts, DB failures, outbox backlog/oldest age, and shutdown drain results.

Health endpoints

Liveness asks whether a process should be restarted. Readiness asks whether that process can serve its assigned role and dependencies. A connected MQTT client alone does not establish useful service readiness.

+

🧭 File overview: internal/observability/health.go

+

Create this complete standard-library file for the first operational instrumentation slice. It exposes liveness, dependency-aware readiness, and low-cardinality Prometheus text metrics. It does not label metrics with device or command IDs.

+

🩺 NewHandler and readiness setters — short summary

Create one per process role and update its dependency flags when DB/MQTT readiness changes.

+

📈 ServeHTTP — short summary

Serves bounded health endpoints and fixed metric names without exposing secrets or arbitrary IDs.

+
📄 Complete file contents
package observability
+
+import (
+	"fmt"
+	"net/http"
+	"sync/atomic"
+)
+
+type Handler struct {
+	role string
+	requiresMQTT bool
+	databaseReady atomic.Bool
+	mqttReady atomic.Bool
+	rejected atomic.Uint64
+	duplicates atomic.Uint64
+	reconnects atomic.Uint64
+	completed atomic.Uint64
+}
+
+func NewHandler(role string, requiresMQTT bool) (*Handler, error) {
+	if role != "api" && role != "ingest" { return nil, fmt.Errorf("role must be api or ingest") }
+	return &Handler{role: role, requiresMQTT: requiresMQTT}, nil
+}
+
+func (h *Handler) SetDatabaseReady(ready bool) { h.databaseReady.Store(ready) }
+func (h *Handler) SetMQTTReady(ready bool) { h.mqttReady.Store(ready) }
+func (h *Handler) RecordRejected() { h.rejected.Add(1) }
+func (h *Handler) RecordDuplicate() { h.duplicates.Add(1) }
+func (h *Handler) RecordReconnect() { h.reconnects.Add(1) }
+func (h *Handler) RecordCompleted() { h.completed.Add(1) }
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	switch r.URL.Path {
+	case "/livez":
+		w.WriteHeader(http.StatusNoContent)
+	case "/readyz":
+		if !h.databaseReady.Load() || (h.requiresMQTT && !h.mqttReady.Load()) {
+			http.Error(w, "required dependency is not ready", http.StatusServiceUnavailable)
+			return
+		}
+		w.WriteHeader(http.StatusNoContent)
+	case "/metrics":
+		w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
+		fmt.Fprintf(w, "mqtt_learning_messages_rejected_total{role=%q} %d\n", h.role, h.rejected.Load())
+		fmt.Fprintf(w, "mqtt_learning_messages_duplicate_total{role=%q} %d\n", h.role, h.duplicates.Load())
+		fmt.Fprintf(w, "mqtt_learning_reconnects_total{role=%q} %d\n", h.role, h.reconnects.Load())
+		fmt.Fprintf(w, "mqtt_learning_work_completed_total{role=%q} %d\n", h.role, h.completed.Load())
+	default:
+		http.NotFound(w, r)
+	}
+}
+
+

🔎 Details: operational meaning

Liveness stays independent of database/MQTT so a dependency outage does not trigger a restart loop. Readiness requires PostgreSQL for both processes and MQTT subscriptions for ingestion. The metrics are counters, not full histogram/queue instrumentation; add queue gauges and latency histograms when the service has those measurements. Use process role as a bounded label and keep IDs in structured logs instead.

+

🧠 Check the diagnosis: After 202 and PUBACK with no device result, what conclusion is justified?




+

🧪 Make a repeatable load and failure scenario

+

Start with a synthetic simulator load that has a fixed duration, device count, message rate, and payload mix. Measure throughput, latency percentiles, memory, queue growth, and errors. Profile after evidence identifies a bottleneck; a large queue is not a throughput fix if admission exceeds processing indefinitely.

+
Injected faultEvidence to collectRecovery check
Broker down/restartReconnect state, Session Present, subscription resultsExpected subscriptions return; duplicate handling stays safe
Simulator offline or restartCommand state, expiry/deadline, session behaviorLate work is safe; status remains honest
Slow/unavailable DBQuery latency/errors, manual Ack policy, backlogNo false durable acceptance; recovery resumes
Duplicate report/resultStable ID and duplicate countersOne business effect; stored outcome remains stable
Denied topicBroker logs/reason and application visibilityNo unauthorized effect or data leakage
Shutdown under loadQueue depth, accepted/rejected, drain deadlineRecoverable work is preserved or loss is explicitly recorded
+

📓 Write a runbook another person can follow

+
  1. State the symptom and its user-visible impact.
  2. Collect command/report ID, timestamps, process logs, metrics, broker evidence, and relevant database state without secrets.
  3. Map the last confirmed evidence to HTTP, application, broker, device, or database boundary.
  4. Give the bounded recovery action and note the risk of retrying a possibly completed physical effect.
  5. Verify recovery with a harmless synthetic operation and record the evidence that changed.
+

✅ Done when

Someone who has not read your code can use the runbook to diagnose one injected command failure. Your load scenario and fault matrix can be repeated, and each metric helps answer an operational question.

+
📋 Short self-check answers
  1. 202 is API acceptance; PUBACK is one MQTT hop. Neither proves device completion.
  2. Readiness reflects dependencies needed for the process's assigned work; liveness only says whether it is functioning enough to keep running.
  3. Metric labels should remain bounded; use logs/traces for high-cardinality IDs.
+

📚 Section checklist: COURSE.md — Section 17. Primary references: Go net/http and Prometheus exposition format.

+

Ask me to help map a runbook symptom to the next useful measurement.

+ +
diff --git a/teaching/lessons/0018-backend-handover.html b/teaching/lessons/0018-backend-handover.html new file mode 100644 index 0000000..700117b --- /dev/null +++ b/teaching/lessons/0018-backend-handover.html @@ -0,0 +1,108 @@ + + + + Lesson 18 — Prepare for the MQTT 5 Backend Handover + +
+

Go MQTT Vending Backend · Lesson 18 · Capstone

+

Turn course knowledge into a safe backend handover

+

Inspect the delivered system from evidence, trace its business flows, and prepare a narrow maintenance change with a rollback plan.

+
🎯 One skill: explain a system you did not design🧠 Separate source evidence from assumptions🧪 Authorized staging only
+

❓ Start with the handover story

You receive a new MQTT 5 backend repository. The course lab uses different topics, IDs, and packages. What must you inspect before proposing an import change or device rollout?

Start with commit, dependency and deployment evidence, then authoritative contracts and end-to-end traces.

+

🧭 Re-establish the actual system

+

Inspect the delivered commit, Go modules, broker configuration, deployment topology, and device contracts. Record which observations come from source, tests, broker configuration, staging, or a human owner. Do not carry lab topic names or identity assumptions into production.

+

🧠 Check the handover: What is the right starting point for a delivered MQTT 5 backend?




+

🔬 Trace three complete business flows

+
  1. HTTP command: API authorization → idempotency/command record → publication and PUBACK → device acceptance/completion → persisted result → API status.
  2. Sale report: device report identity → topic/payload validation → deduplication and transaction → MQTT acknowledgment decision → application receipt or retry.
  3. Catalog/configuration: backend revision/job → publish → device validation/application → reported revision/result → retry and stale-revision policy.
+

For each step, name the process and durable state owner. Record actual identifiers, topic ACLs, schema versions, timeout/expiry behavior, and evidence. If source cannot answer a question—such as physical device semantics or broker recovery targets—put it in the handover question list instead of guessing.

+

🔀 Compare protocol generations by behavior

+

Build an MQTT 3.1.1-to-5 comparison for each real flow: connection/session behavior, reason handling, expiry, properties, request/response, subscription options, acknowledgement timing, and client-library lifecycle. Mark feature support by device cohort. A legacy device may ignore or lack MQTT 5 properties; a server migration does not make the device understand them.

+

🛠️ Propose one narrow maintenance change

+

Choose a change with a reproducible symptom and bounded blast radius. Define expected behavior, regression coverage, observability, rollout checks, and rollback criteria before implementing. Use authorized staging simulators for acceptance checks; production migration or deployment is separate scope.

+

Evidence checklist

Commit/dependencies, contract source, topic and ACL ownership, command/report identities, persistence boundaries, retry/expiry rules, and process readiness.

Questions for owners

What exactly does device acknowledgment mean? How is broker persistence configured? What recovery target applies? Who rotates credentials and owns escalation?

+

📄 Complete handover artifact: docs/backend-handover.md

+

Create this report from the delivered checkout. Replace every bracket with evidence or an explicit unanswered question. It is a complete documentation artifact for this non-coding capstone; it does not prescribe Go source changes.

+
🗂️ Full file contents
# MQTT backend handover
+
+## Scope and evidence
+
+- Repository and commit: `[URL/path and commit SHA]`
+- Review date and reviewer: `[date, name]`
+- Runtime environments inspected: `[local, staging, production read-only, other]`
+- Evidence collected: `[source paths, tests, broker config, logs, queries, owner answers]`
+- Facts that remain unverified: `[list; do not infer from the learning lab]`
+
+## Runtime map
+
+| Process | Entry point | MQTT Client ID | Owns subscriptions/publication | Durable dependencies | Readiness evidence |
+| --- | --- | --- | --- | --- | --- |
+| `[role]` | `[path]` | `[identity]` | `[filters and publish classes]` | `[database/queue]` | `[endpoint or observation]` |
+
+## Wire and identity contract
+
+- MQTT protocol versions by device cohort: `[evidence]`
+- Device identity and credential mapping: `[source/config/owner]`
+- Topic namespace and ACL owner: `[evidence]`
+- Payload schema versions and compatibility policy: `[evidence]`
+- Correlation, idempotency, and deduplication keys: `[evidence]`
+- MQTT 3.1.1 versus MQTT 5 behavior differences: `[feature, cohort, test result]`
+
+## End-to-end traces
+
+### HTTP command
+
+`[authorization]` → `[durable acceptance]` → `[publish and PUBACK evidence]` → `[device acceptance]` → `[completion/result]` → `[status query]`
+
+For each boundary, record process, stable ID, durable owner, and evidence: `[details]`.
+
+### Sale report
+
+`[device report ID]` → `[topic/payload validation]` → `[transaction and dedupe]` → `[MQTT acknowledgment decision]` → `[application receipt/retry]`
+
+For each boundary, record process, stable ID, durable owner, and evidence: `[details]`.
+
+### Catalog or configuration update
+
+`[revision/job]` → `[publish]` → `[device validation/application]` → `[reported revision/result]` → `[stale/retry policy]`
+
+For each boundary, record process, stable ID, durable owner, and evidence: `[details]`.
+
+## Recovery and operations
+
+- Session persistence, expiry, and reconnect behavior: `[evidence]`
+- Database migrations and backup/restore owner: `[evidence]`
+- Outbox/retry/dead-letter behavior: `[evidence]`
+- Liveness and readiness meaning by process: `[evidence]`
+- Failure reproduced and diagnostic path: `[symptom, IDs, evidence, layer, recovery]`
+- Physical-effect ambiguity and reconciliation policy: `[evidence or owner question]`
+
+## Proposed maintenance change
+
+- Reproduction and observed result: `[steps and evidence]`
+- Expected behavior and acceptance check: `[specific]`
+- Files and wire contracts affected: `[paths; distinguish internal from device-facing change]`
+- Regression coverage and staging cohort: `[checks and devices]`
+- Rollout guardrails and abort signal: `[metrics/threshold/owner]`
+- Rollback condition and recovery action: `[specific]`
+
+## Questions requiring an owner
+
+1. Device acknowledgment semantics: `[owner, answer, or unresolved]`
+2. Broker persistence and recovery target: `[owner, answer, or unresolved]`
+3. Credential rotation and revocation: `[owner, answer, or unresolved]`
+4. Escalation and physical reconciliation: `[owner, answer, or unresolved]`
+
+## Handover decision
+
+- Confirmed safe maintenance scope: `[narrow change or none]`
+- Explicitly excluded rollout scope: `[devices/environments/actions]`
+- Evidence reviewer still needs: `[list]`
+
+

🧪 Capstone review

+
  1. Explain one command, one sale, and one catalog/config flow end-to-end with evidence at each boundary.
  2. Inject one staging failure and diagnose it with the Section 17 runbook.
  3. Review mixed MQTT 3.1.1/5 interoperability and list where feature loss is expected.
  4. Present one narrow change with reproduction, behavior, regression test, rollout checks, and rollback condition.
  5. Deliver a question list for facts that source and staging cannot establish.
+

🏁 Course completion

You are ready to maintain the delivered system when you can explain the traced flows, diagnose an injected failure, justify a small change with evidence, and distinguish confirmed behavior from unanswered operational questions. This checkpoint does not itself authorize a production rollout.

+
📋 Short self-check answers
  1. Reinspect the delivered checkout and authoritative contracts before selecting APIs.
  2. A broker ACK, application result, durable business record, and physical effect are distinct evidence.
  3. Compatibility must be checked per device cohort and behavior, not inferred from the broker/client migration.
+

📚 Course handover checklist: COURSE.md — Section 18. Primary references: OASIS MQTT 5.0 Standard, OASIS MQTT 3.1.1 Standard, and Go command documentation.

+

Ask me to review a completed handover report and help separate confirmed facts from unresolved owner questions.

+ +
diff --git a/teaching/reference/http-mqtt-evidence.html b/teaching/reference/http-mqtt-evidence.html new file mode 100644 index 0000000..12b6409 --- /dev/null +++ b/teaching/reference/http-mqtt-evidence.html @@ -0,0 +1,45 @@ + + + + + + HTTP and MQTT Evidence — Quick Reference + + + +
+

Robot Market · Quick reference

+

Three acknowledgements, three meanings

+

When a backend tells a caller that a device command is progressing, name the component that produced each piece of evidence.

+ +
+

Evidence map

+ + + + + + + +
EvidenceWho produced it?What it supportsWhat it does not prove
HTTP 202Go HTTP serverThe request was accepted for processing.Broker publication or device execution.
QoS 1 PUBACKMQTT receiver on this hopThe corresponding QoS 1 PUBLISH exchange was acknowledged, subject to the reason code.That a device accepted or completed the business action.
Command resultDevice application, delivered back through MQTTThe result contract reports accepted, completed, or rejected work.That the report is authentic without identity and authorization checks.
+
+ +
+
API admission
HTTP 202
→ +
Broker hop
PUBACK
→ +
Device application
Command result
+
+ +
+

Use this sentence

+

“The API accepted command C. The broker acknowledged the publish. The device result is still pending.”

+

If the API process keeps command state only in memory, an HTTP 202 is not a promise that the record survives a process restart.

+
+ + +
+ + diff --git a/teaching/reference/message-processing-lifecycle.html b/teaching/reference/message-processing-lifecycle.html new file mode 100644 index 0000000..9c3674c --- /dev/null +++ b/teaching/reference/message-processing-lifecycle.html @@ -0,0 +1,18 @@ + + +Message Processing Lifecycle — Quick Reference +
+

Robot Market · Quick reference · Lessons 5–7

From bytes to a matched command result

+

Use this map to locate the boundary where a failure happened. An event in one row does not automatically prove the next row.

+
+ + + + + + +
StageCode ownerSuccess meansStill does not prove
Route + contracttopic.go builders/parser, DecodePublish names and filters are distinguished; topic shape and typed payload passed validation.Who published; Mosquitto ACL owns that access decision.
Application stateService + storeA use case updated the in-memory view or recorded an outcome.That the record survives a process restart.
Queue admissionBounded dispatcherWork entered a finite RAM queue or returned Full/Stopping.That a worker completed or that MQTT will redeliver.
Command publishAutoPaho adapterPUBACK acknowledged the QoS 1 publication hop.That the device received, accepted, or completed the business action.
Device resultResponse handler + command storeValidated device/application evidence matched identity and correlation.Authenticity without broker and application authorization.
HTTP statusHTTP handlerThe API represented its current known command state.That an asynchronous device operation is already complete.
+
Parse + validate
→
Apply use case
→
Admit bounded work
→
Publish + correlate
→
Report evidence
+

Fast incident questions

  1. Which process produced this evidence?
  2. What state changed, and where does it live?
  3. Could the earlier stage have acknowledged before this stage finished?
  4. What does restart, timeout, or queue saturation erase or leave unknown?
+ +