Files
mqtt-5-learning/lessons/07-command-response.md
T

602 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🔁 Section 7 — Build reliable command and response behavior
## 🎬 Start with the system story
> **❓ Question:** **The broker acknowledged a backend's command publication. How can the backend match a later device response to the right request—and what should it report if no response arrives?**
The MQTT acknowledgement covers delivery on one hop. The device's application response is a separate publication. The backend therefore needs application-level command identity, correlation, a pending state registered before publication, and a timeout policy that does not claim the device definitely did nothing.
```mermaid
sequenceDiagram
participant H as HTTP/backend caller
participant G as Go command service
participant B as Broker
participant D as Device
H->>G: Request command
G->>G: Create command ID and pending waiter
G->>B: PUBLISH command with correlation data
B-->>G: PUBACK: broker hop accepted
B->>D: PUBLISH command
D->>D: Validate, deduplicate, act
D->>B: PUBLISH correlated application result
B->>G: PUBLISH result
G->>G: Match identity/correlation; update state
G-->>H: Accepted / completed / rejected / unknown
```
**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.
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.
## ↩️ MQTT request/response is an application conversation
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 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
// MQTT 5 carries the reply destination and request identity in packet properties.
request := &paho.Publish{
Topic: "lab/v1/devices/device-001/commands",
QoS: 1,
Retain: false,
Payload: commandJSON,
Properties: &paho.PublishProperties{
ContentType: "application/json",
ResponseTopic: "lab/v1/devices/device-001/responses",
CorrelationData: append([]byte(nil), correlation...),
},
}
// Send this packet only after the pending request has been registered.
```
Pass `request` to the bounded publication operation after registering pending state. The simulator reads `ResponseTopic` to find where to publish its result and copies Correlation Data unchanged into the result's properties. Correlation bytes are opaque; they need not be text.
**❓ Question:** **A command for device-001 asks the simulator to reply on `lab/v1/devices/device-002/responses`. What could go wrong if it publishes there?**
**💬 Answer:** The reply could appear in device-002's message stream. The simulator checks that `ResponseTopic` exactly matches its permitted topic, `lab/v1/devices/device-001/responses`. If it does not match, the simulator rejects the command and publishes no reply. Correlation Data matches a result to a request; it does not authorize the destination.
### ↩️ Trace a fast response and a lost response
1. Backend establishes its response subscription and checks SUBACK.
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.
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.
| Identifier | Owner and lifetime | Why it cannot replace the others |
| --- | --- | --- |
| MQTT packet ID | Library, per protocol exchange/session direction, reusable | Identifies a transport handshake, not a permanent command |
| 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 |
### ♻️ 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.
Concurrent duplicates need atomic admission: a lookup followed by an unlocked effect allows two workers to both observe "not seen". Reserve the ID with its fingerprint before executing, and define what a second request sees while the first is in progress. A real effect followed by a crash before storing its result remains a separate failure window; the in-memory simulator only demonstrates the idea.
<details>
<summary><strong>❓ Question: The broker acknowledged, but no device result arrived. Which status is justified?</strong></summary>
**💬 Answer:** Only the transport acknowledgement is established. The device may be offline, may have rejected the request, may still be executing, or may have executed with a lost response. Once its deadline expires, expose unknown outcome. Retry only under the same business identity and a deliberate idempotency policy.
</details>
## 💾 1. Preserve MQTT 5 request metadata
> **❓ Question:** **How can a responder know where to send a reply and the requester know which command the reply belongs to?**
Use the MQTT 5 Response Topic and Correlation Data as transport metadata, while retaining a stable application command ID and checking that the response destination is authorized.
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.
`internal/commands/store.go`:
```go
package commands
import (
"fmt"
"bytes"
"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`:
```go
package commands
import (
"context"
"crypto/rand"
"fmt"
"time"
"example.com/mqtt5-learning/internal/contracts"
)
type Publisher interface {
PublishCommand(context.Context, string, contracts.Command, string, []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) {
// 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"}
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...))
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
}
```
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.
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.
```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 string, correlation []byte) (byte, error) {
// Authorize the reply destination before putting it in the MQTT packet.
authorized := "lab/v1/devices/" + deviceID + "/responses"
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") }
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",
QoS: 1,
Payload: payload,
Properties: &paho.PublishProperties{
ContentType: "application/json",
ResponseTopic: responseTopic,
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
}
```
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
type Message struct {
Topic string
Payload []byte
Retain bool
ResponseTopic string
CorrelationData []byte
}
// Copy properties alongside payload before the receive packet leaves the callback.
properties := packet.Properties
message := Message{Topic: packet.Topic, Payload: append([]byte(nil), packet.Payload...), Retain: packet.Retain}
if properties != nil {
message.ResponseTopic = properties.ResponseTopic
message.CorrelationData = append([]byte(nil), properties.CorrelationData...)
}
```
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.
```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
}
```
## 🧩 2. Model evidence explicitly
> **❓ Question:** **What should the backend show after a PUBACK but before any device response arrives?**
Represent broker delivery evidence separately from device acceptance, completion, rejection, and timeout/unknown outcome.
Store command ID, expected device, correlation bytes, creation/deadline times, business status, and independent transport acknowledgement evidence. Start with created → pending delivery → accepted → completed or rejected. A ping simulator can send accepted followed by completed. Response timeout means unknown outcome; it does not establish that no effect happened.
Protect concurrent access and define allowed transitions. A completed or rejected command must not regress on a duplicate accepted response. Compare command identity, topic/body device identity, and correlation before applying a response. Keep timed-out records for a bounded retention period even after removing their waiters, so status can explain the uncertainty.
## ♻️ 3. Make the simulator idempotent
> **❓ Question:** **If QoS 1 or an application retry delivers the same command again, how does the device avoid repeating a non-idempotent effect?**
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.
```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 {
return requested == "lab/v1/devices/"+deviceID+"/responses"
}
// 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
}
```
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
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
}
```
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
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()
base := "lab/v1/devices/" + cfg.MQTT.DeviceID
inbox := make(chan mqtt.Message, 16)
manager, err := mqtt.Start(ctx, cfg, []string{base+"/commands"}, 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, base+"/telemetry", 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) }
}
}
}
```
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
> **❓ Question:** **If a response is late or missing, what can the backend safely conclude, and what should happen to a later result?**
Exercise timeouts and duplicates while keeping uncertainty visible; a timeout is not proof that the device did nothing.
Use the completed command service and simulator handler to trace 20 concurrent pings with separate IDs. Observe delayed, duplicate, wrong-device, wrong-correlation, and out-of-order responses. Check that each command receives only matching evidence. A retry keeps the same stable command ID; a publish timeout alone does not prove that the device received nothing.
```sh
mkdir -p internal/commands internal/simulator
gofmt -w internal cmd
go test -race ./...
```
Run `mkdir -p` before saving the service and simulator package files. `gofmt` formats your edits and `go test -race ./...` executes the module's tests with concurrency instrumentation. A fake transport is a small in-process implementation of the caller's transport interface: it lets tests deliberately return errors or responses without needing a broker. An injected clock makes deadline tests deterministic without waiting in real time.
When copying Correlation Data, allocate a new byte slice so later processing owns stable bytes. Comparing correlation bytes means comparing their contents, not pointer addresses. Before updating a shared command map, validate the response and hold its lock only for the state transition. Publish outside the lock so a network delay cannot freeze every command.
The implementation handles responses that arrive before the publish call returns because pending state is inserted first and broker acknowledgement updates only its own fields. Inspect the matching, timeout, deduplication, and terminal-state branches in the complete files above; a fake transport can isolate each case without a broker.
**Done when:** Concurrent requests match correctly, identical duplicates cause one effect, unexpected destinations are rejected, and late responses follow a documented policy. Explain why PUBACK and Correlation Data cannot prove business completion.
## ✅ Result — the story so far
The backend now treats a ping as a tracked command: it records the command ID, expected device, correlation data, and deadline before publishing. The simulator checks the command and authorized response destination, applies the simulated effect once for that stable ID, and publishes a result. The backend matches that result to the pending command and updates its status. A broker PUBACK still means only that the broker acknowledged its hop; a missing response leaves the outcome unknown, not necessarily unexecuted.
Previous: [06-concurrency-cancellation.md](06-concurrency-cancellation.md). Next: [08-http-commands.md](08-http-commands.md).