Skip to content

backlog feature: fast-lane queue for nodelay commands (USE_BACKLOG_FASTLANE)#24882

Open
joluxer wants to merge 7 commits into
arendst:developmentfrom
joluxer:pr/backlog-fastlane
Open

backlog feature: fast-lane queue for nodelay commands (USE_BACKLOG_FASTLANE)#24882
joluxer wants to merge 7 commits into
arendst:developmentfrom
joluxer:pr/backlog-fastlane

Conversation

@joluxer

@joluxer joluxer commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Description

Part of the backlog improvement series documented in discussion #24776.

Implementation choices are open to discussion. If you'd prefer a different approach, please comment -- I'll revise.

Situation

Commands queued via Backlog0/2 share the timer-gated queue with timed
sequences. The SO166 post-external-command window (100 ms) stalls the
whole queue, including entries that explicitly requested no delay. Under
sustained MQTT load this made the nodelay path unreliable.


Changes

Opt-in: USE_BACKLOG_FASTLANE at build time, SetOption167 at runtime.

Backlog0/2 commands are routed to a separate fast-lane queue at enqueue
time and drained per BacklogLoop() call with no timer gate, within a
burst budget. At least one entry is always drained. The budget defaults
to the current Sleep setting; Backlog19 N overrides it at runtime
(0=Sleep, 50-250 ms, out-of-range clamped).

The sleep-blocking check in the main loop is corrected to test
pre-drain state: whether the fast queue has pending work or the
timed-lane timer has lapsed.

Backlog19 N - burst budget (runtime, non-persistent).

Backlog20 extended: FastLane, FastDepth, FastDrained, FastBudget,
FastBudgetMs.

Backlog21-29 extended: Lane field ("F"/"T") per entry.


Memory impact (measured)

Ref: pr/backlog-diagnostics tip (a97fc92c)

Target Flash RAM IRAM
tasmota32 +1100 +24 0
tasmota-4M +860 +8 0

Cumulative PR6-PR10 vs development (5e8019d):

Target Flash RAM IRAM
tasmota32 +3500 +64 0
tasmota-4M +3744 +72 0

Tested

Build-tested on ESP8266 (tasmota-4M) and ESP32 (tasmota32).
Target test on ESP8266 (ESP-12F): fast-lane drain confirmed under
sustained MQTT load; SetOption167 toggle at runtime verified; Backlog19
budget adjustment under load confirmed. A device in continuous
production use showed significant behavioral stabilization.

Requires pr/backlog-diagnostics: the fast-lane queue extends Backlog20/21-29 diagnostics and relies on the byte-limit infrastructure for queue-depth tracking.

Checklist:

  • The pull request is done against the latest development branch
  • Only relevant files were touched
  • Only one feature/fix was added per PR and the code change compiles without warnings
  • The code change is tested and works with Tasmota core ESP8266 V.2.7.8
  • The code change is tested and works with Tasmota core ESP32 V.3.3.8 from Platform 2026.05.50
  • I accept the CLA.

joluxer added 7 commits July 4, 2026 21:10
BacklogLoop previously left backlog_timer at the value written by
CommandHandler at the start of ExecuteCommand -- millis() at command
start time plus P_BACKLOG_DELAY. After a long execution (rule-trigger
chain) that point was already past: TimeReached() returned true
immediately on the next iteration, draining the queue without pause
regardless of SetOption34. A nested CmndBacklog reset the timer to
millis() during execution, compounding the problem: the queue drained
as fast as commands were enqueued.

BacklogLoop now unconditionally sets backlog_timer after each timed
drain step. The inter-command interval becomes deterministic: exactly
P_BACKLOG_DELAY between drain steps, regardless of what CommandHandler
or nested Backlog commands wrote during ExecuteCommand.

Exception: CmndDelay called from within a drain sets backlog_delay_guard
(detected via backlog_mutex=true). BacklogLoop preserves the timer for
that one step and clears the guard. External commands can still shorten
an active delay -- that matches original Tasmota behaviour.
Introduce SettingsParam(index) as a named function returning a uint8_t
reference to Settings->param[index].

Adds bounds protection: an index >= PARAM8_SIZE returns a reference to a
stable zero byte instead of an out-of-bounds access.

Replace three direct Settings->param[] accesses in the SetOption handler
(CmndSetoption, GetOption, CmndSetoptionBase) to use the new accessor.
Move the backlog queue, timer, and control flags out of TasmotaGlobal_t and
the global .ino scope into a dedicated translation unit (support_backlog.cpp).
The state was accessible from any .ino file; the new module limits mutation
to explicit paths through the Backlog:: API.

All internal state is held in an anonymous namespace. The public interface is
declared in support_backlog.h. A one-line support_backlog.ino adapter keeps
BacklogLoop() visible to the unity build.

Removes from TasmotaGlobal_t: backlog_timer, backlog_nodelay, backlog_mutex,
backlog_delay_guard, backlog_no_mqtt_response. Removes global LList<char*>
backlog.

Adds SuppressMqttResponse() to support.ino as the designated mutation path
for TasmotaGlobal.no_mqtt_response from backlog context.

No intended behavior change.
SetOption166 (default 0) controls whether external commands (MQTT, Serial,
Button, ...) extend the Backlog drain window after execution.
SO166=0: CommandHandler sets the drain timer to millis() + BACKLOG_EXT_DELAY
(100 ms, separate from SO34, overridable at build time via user_config_override.h).
SO166=1: BacklogLoop is the sole timer owner; external commands do not stall
the drain. The inter-command delay within a sequence (SO34) is unchanged.

Each queue entry now carries a flavor byte (bit0=nodelay, bit1=no_mqtt_resp)
baked in from staged flags at enqueue time. D_CMND_NODELAY is resolved in
CmndBacklog() and ExecuteCommandBlock() at enqueue rather than stored as a
queue token. Loop() reads the flavor byte per drain step; _nodelay and
_no_mqtt_resp each split into _staged (set by callers) and _current (set
from the flavor byte, read by IsNodelay()).

ExecuteCommandBlock() resets staged flags to plain-Backlog defaults before
its tokenisation loop: IF blocks no longer inherit ambient state from a
preceding CmndBacklog() call.

ScheduleDelay() is a no-op when _nodelay_current is set.

Default-configuration behavior: the inter-command delay (SO34) is unchanged.
The post-external-command drain window changes from SO34 to BACKLOG_EXT_DELAY
(100 ms vs. 200 ms default) -- this is intentional and separately configurable.
Backlog16 N: set runtime chunk size for Backlog21-29 paged content dump
  (default 20; non-persistent).
Backlog17 0/1: enable per-drain-step trace log at INFO level (BLG: tag).
  Logs queue depth before drain, flavor byte, and command string per entry.
  Build flag BACKLOG_TRACE_SOURCE extends the trace with the command source.
Backlog20: JSON statistics snapshot covering depth, timer state, staged
  and current flags, per-operation counters, high-watermark values, and
  runtime config (SO34/SO166, chunk size).
  Bytes/Discarded/MaxBytes report 0 in this commit; the follow-up
  byte-limit commit fills them with real values.
Backlog21-29: paged queue content as JSON. Page = index - 21, chunk
  size from Backlog16. Entries appear on individual log lines and in an
  assembled MQTT/Web-Console response.

The entry header is generalized to accommodate the optional
  BACKLOG_TRACE_SOURCE source byte. A compile-time offset replaces the
  previous hardcoded value, keeping the conditional out of every access
  site. The source parameter on EnqueueCmd/InsertCmd is unconditional
  with a "not annotated" default.

A running depth counter alongside the linked-list length serves as a
  cross-check; underflow is logged at error level. Per-operation counters
  for drain, enqueue, insert, and mutex-skip feed the Backlog20 snapshot.
Without a size bound the queue can grow until a malloc fails mid-sequence,
leaving partial command sets in flight and the node unreachable. The byte
limit trades completeness for availability: a sequence that would exceed
the limit is rejected whole instead of partially enqueued. The node stays
reachable and reports the rejection in the log.
Empirically confirmed on ESP8266: 30+ min under sustained MQTT load
without crash; limit adjustment under load drains the queue cleanly.

Backlog18 N: set the byte limit at runtime (0 resets to the compile-time
  default). Clamped to a minimum confirmed stable on ESP8266 under load.
  Platform-dependent compile-time defaults are in tasmota.h; override via
  user_config_override.h.

Accounting covers the command string, the linked-list node, and
  heap-allocator bookkeeping per allocation - the overhead a string-length
  check alone would miss. The formula is centralized so that a change to
  the overhead model touches one place. LList gains a public node-size
  constant so the overhead calculation stays inside the queue module
  rather than depending on LList internals.

Per-command fallback checks guard the rules IF/ENDIF insertion path and
  any direct caller that bypasses the sequence-level gate.

Backlog20 (added in the preceding diagnostics commit) now reports real
  byte counters and the active limit instead of placeholder zeros.
Nodelay sequences shared the timer-gated queue with timed sequences.
An external command triggering the SO166 drain window stalled the whole
queue by 100 ms even for commands that were queued without delay. A
dedicated fast lane removes that coupling.

When USE_BACKLOG_FASTLANE is defined and SetOption167 is active, nodelay
commands are placed in a separate queue and drained within a per-call
time budget, bypassing the SO34/SO166 timer gate. At least one command
is drained per BacklogLoop() call regardless of budget. The budget
defaults to the current main-loop sleep interval so the burst scales
naturally; Backlog19 N overrides it at runtime (0=sleep, 50-250 ms).

Common drain logic is factored so both lanes share the same path; the
per-entry trace log (Backlog17) tags each entry by lane so fast-lane
and timed-lane activity are distinguishable in the log.

The sleep-blocking check is corrected: the previous test read a
post-drain state for a pre-drain decision. The replacement checks
whether the fast queue has pending work or the timed-lane timer has
already lapsed - both genuine pre-drain signals.

Backlog20 reports fast-lane depth, drain count, and effective budget.
Backlog21-29 includes a lane field per entry.
@s-hadinger

Copy link
Copy Markdown
Collaborator

Hi, I understand the implementation, but I fear it looks a little bit as an overkill. Backlog is supposed to serve very minimalistic use cases, whereas full scripting with Berry (ESP32 and above) offer much more flexibility. Do you have concrete examples where this should be used?

I also fear that additional backlog options makes it hard to use and understand to most users that have basic needs.

@arendst any opinion?

@s-hadinger

s-hadinger commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Here are the findings found by gpt-5.5: can you double check please? (if we go for merging)

Findings:

  1. Rules IF-block NoDelay becomes sticky

    • xdrv_10_rules.ino:2014
    • In ExecuteCommandBlock(), Backlog::SetNodelay(true) is set when a NoDelay token is seen, but it is never reset after inserting the next real command.
    • In CmndBacklog(), this is handled with nodelay_oneshot; the rules path lacks the equivalent.
    • Impact: inside IF ... ENDIF command blocks, NoDelay likely affects all subsequent inserted commands, not just the next one. It may also leave staged state true until another caller resets it.
  2. NoDelay timing semantics changed

    • support_command.ino:612, support_backlog.cpp:412-413
    • Old behavior processed NoDelay as a queue entry at drain time. The delay before the NoDelay marker still occurred; the following command then ran in the same drain step and reset the next timer.
    • New behavior bakes NoDelay into the next command and uses lookahead to skip the delay before that command.
    • Impact: sequences like Backlog cmd1; NoDelay; cmd2; cmd3 now run cmd2 earlier than before. This can break scripts depending on inter-command settling time.
  3. Fast-lane mode appears to still modify the timed-lane timer

    • support_backlog.cpp:210, support_backlog.cpp:396
    • The fast-lane comment says _timer is owned only by the timed lane, but each fast-lane ExecuteCommand() still enters CommandHandler(), which calls Backlog::OnCommandExecuted() and sets _timer.
    • Impact: when USE_BACKLOG_FASTLANE is enabled, fast-lane commands can delay timed-lane queue execution by BACKLOG_EXT_DELAY, contradicting the design comments and likely causing ordering/timing surprises.
  4. SetOption167 is defined but not properly exposed/named in settings

    • tasmota.h:225 defines SO_BACKLOG_FASTLANE 167.
    • tasmota_types.h:204 still has spare21.
    • tasmota_types.h:218 sets MAX_SETOPTION_USED = 166.
    • Impact: SetOption167 can be used through generic decoding, but it is hidden from normal SetOption reporting and not named in the bitfield. If this option is intentional, spare21 should be renamed and MAX_SETOPTION_USED should include 167; otherwise SO_BACKLOG_FASTLANE should not be advertised.
  5. Backlog18 accepts negative payloads as huge unsigned limits

    • support_command.ino:593-596, support_backlog.cpp:164-166
    • Backlog18 -1 casts -1 to uint32_t, setting the queue byte limit to 0xFFFFFFFF.
    • Impact: the new heap safety net can be accidentally or deliberately disabled via a negative value, enabling memory pressure/DoS from large backlogs. This should reject negative values or clamp to a sane upper bound.
  6. Queue dump emits unescaped command strings into JSON

    • support_backlog.cpp:475-486
    • _DumpEntry() inserts raw queued commands into JSON with %s.
    • Impact: commands containing quotes, backslashes, or control characters produce invalid JSON and can inject fake fields into diagnostic output. Use EscapeJSONString(cmd).c_str() or equivalent.
  7. Behavioral change in external-command delay

    • support_command.ino:470, tasmota.h:221-224
    • Old CommandHandler() reset backlog delay to SetOption34 for every command.
    • New code uses BACKLOG_EXT_DELAY default 100 ms, with SetOption166 to disable.
    • Impact: existing configurations with non-default SetOption34 may see backlog drain timing change when external commands arrive during a backlog. This may be intended, but it is a compatibility change and should be explicitly accepted/tested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants