Files
MeshCore/src/helpers/MQTTPacketQueuePolicy.h
T
agessaman b0cf29fb33 test(mqtt): extract + test remaining inline MQTT decision points (Phase 6)
Close the host-testable gaps named in Phase 6 of STABILITY_TESTABILITY_HANDOFF.md
by moving the last inline decision logic into the pure, host-tested policy seams:

- WiFi STA reconnect backoff: extract the inline ladder + wrap-safe timing from
  handleWiFiConnection() into MQTTConnectionPolicy::{wifiReconnectBackoffMs,
  wifiReconnectDue,nextWifiBackoffAttempt}. Behavior-preserving (elapsedMs is the
  wrap-safe form of the old ULONG_MAX branch); ladder/clamp/attempt-cap unchanged.
- Publication outcome pairing: name the (packet, raw) -> delivered contract as
  MQTTPacketQueuePolicy::queuedPacketPublished() and wire both queue-drain sites;
  partial success = completed, not retried.
- Freeze MQTTPublicationType enum values in a test (the bridge-side MQTTMessageType
  alignment is already enforced by a compile-time static_assert).

Adds host tests for all three (exact boundaries + millis() rollover). Native suite
green (13 dirs); non-PSRAM observer firmware smoke build compiles.

WebConfig batch/reboot/stop state-machine extraction and queue-orchestration
coverage remain open (tracked in the Phase 6 status).
2026-07-19 00:50:15 -07:00

117 lines
4.2 KiB
C++

#pragma once
#include <stddef.h>
#include <stdint.h>
// Pure queue/backpressure policy shared by the FreeRTOS and circular-buffer
// MQTT packet queues. Keeping the timing and retry decisions here makes the
// production behavior deterministic under host tests without mocking either
// queue implementation or the MQTT client.
namespace MQTTPacketQueuePolicy {
static const uint32_t kDisconnectedStaleMs = 300000UL;
static const size_t kBacklogThreshold = 5;
static const uint8_t kGentleDrainCount = 1;
static const uint8_t kBurstDrainCount = 5;
static const uint32_t kGentleDrainBudgetMs = 30UL;
static const uint32_t kBurstDrainBudgetMs = 100UL;
static const uint8_t kMaxQos0RetryAttempts = 3;
static const uint32_t kRetryDelayBaseMs = 300UL;
static const uint32_t kRetryDelayJitterMs = 200UL;
// Unsigned subtraction is the standard millis() idiom and remains correct
// across one 32-bit counter rollover.
static inline uint32_t elapsedMs(uint32_t now, uint32_t then) {
return now - then;
}
enum class EnqueueAction : uint8_t {
Enqueue,
EvictOldestThenEnqueue,
Reject
};
static inline EnqueueAction enqueueAction(size_t queue_count, size_t capacity) {
if (capacity == 0) return EnqueueAction::Reject;
return queue_count >= capacity
? EnqueueAction::EvictOldestThenEnqueue
: EnqueueAction::Enqueue;
}
// disconnected_since == 0 means tracking has not started. The bridge records
// the first disconnected observation and asks this helper on later cycles.
static inline bool shouldFlushDisconnected(uint32_t now,
uint32_t disconnected_since,
uint32_t stale_ms = kDisconnectedStaleMs) {
return disconnected_since != 0 && elapsedMs(now, disconnected_since) >= stale_ms;
}
struct DrainBudget {
uint8_t max_packets;
uint32_t max_time_ms;
};
static inline DrainBudget drainBudget(size_t queue_count) {
if (queue_count > kBacklogThreshold) {
return {kBurstDrainCount, kBurstDrainBudgetMs};
}
return {kGentleDrainCount, kGentleDrainBudgetMs};
}
static inline bool drainTimeAvailable(uint32_t now, uint32_t started_at,
uint32_t budget_ms) {
// Preserve the bridge's inclusive boundary: work may begin at exactly the
// configured limit, but not one millisecond later.
return elapsedMs(now, started_at) <= budget_ms;
}
// retry_attempts distinguishes an unscheduled packet from a scheduled retry
// whose deadline wrapped to exactly zero. Deadlines are always less than
// 500 ms away, so the half-range comparison is unambiguous.
static inline bool retryReady(uint32_t now, uint32_t next_retry_ms,
uint8_t retry_attempts) {
if (retry_attempts == 0) return true;
return elapsedMs(now, next_retry_ms) < 0x80000000UL;
}
enum class RetryAction : uint8_t {
Complete,
Schedule,
Drop
};
struct RetryDecision {
RetryAction action;
uint8_t retry_attempts;
uint32_t delay_ms;
uint32_t next_retry_ms;
};
// A queued packet counts as delivered if EITHER its structured-packet publish
// or its raw-frame publish reached at least one slot. Partial success (one
// succeeds while the other fails or was not attempted) is still success — the
// packet completes and is not retried. This is the (packet, raw) outcome pairing
// fed to retryDecision(); naming it keeps the "partial publish = done" contract
// explicit and host-tested rather than inline in the bridge's queue drain.
static inline bool queuedPacketPublished(bool packet_published,
bool raw_published) {
return packet_published || raw_published;
}
static inline RetryDecision retryDecision(bool any_published,
uint8_t retry_attempts,
uint32_t now) {
if (any_published) {
return {RetryAction::Complete, retry_attempts, 0, 0};
}
if (retry_attempts >= kMaxQos0RetryAttempts) {
return {RetryAction::Drop, retry_attempts, 0, 0};
}
const uint32_t delay = kRetryDelayBaseMs + (now % kRetryDelayJitterMs);
return {RetryAction::Schedule, static_cast<uint8_t>(retry_attempts + 1),
delay, now + delay};
}
} // namespace MQTTPacketQueuePolicy