Skip to content

pillar: retry deferred info messages the controller may still accept - #6304

Merged
eriknordmark merged 7 commits into
lf-edge:masterfrom
eriknordmark:deferred-queue-retry-transient
Aug 21, 2026
Merged

pillar: retry deferred info messages the controller may still accept#6304
eriknordmark merged 7 commits into
lf-edge:masterfrom
eriknordmark:deferred-queue-retry-transient

Conversation

@eriknordmark

@eriknordmark eriknordmark commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

This bug has been present for a while, and I discovered it by chance due to long running sequence of old tests running and one failing and that failure correlated with the controller being updated and restarted. Turns out that the app INFO message with its latest RUNNING status was never delivered to the controller even though those should be reliably delivered. Details below.

Note that this deferrred queue has been a source of issues in the past so this is a somewhat risky fix. To balance that, the fix relaxed the ordering so that if some message sees failures from the controller (due to some bug), it should not result in blocking the sending of the other info messages.

Separately there is an EVE API PR lf-edge/eve-api#154 to add some more metrics around these failures and deferred queuing to get better visibility in production systems.


A device tells the controller about an application, volume, content tree,
blob, network instance, hardware or cluster change only when that change
happens; nothing re-asserts the state afterwards. Yet the deferred queue
treated any HTTP status from 400 to 599 as a reason to throw the message
away, logging a single line at the lowest severity and then counting the
message as sent. A few seconds of controller-side trouble was therefore
enough to leave the controller's view of an object wrong until that object
next changed - for a steady-state application, possibly never.

A case in point is a 503 error when the controller is updating; post update the controller will have missed any updates for these object.

The discard was introduced in 2018 for a controller that no longer knows
about an object and rejects the request itself (return400, matching exactly
400). Server errors were swept into it in 2020 by a commit whose subject was
about not trying the other ports on a 503; the same commit attached the
comment "Return 4xx and 5xx without trying other interfaces" to the shared
flag, which is why the field has documented the port-selection behavior while
the queue silently discarded data ever since.

This PR decides retention from the status code:

  • A rejection of the request is still given up on - 400, 404, 405, 409, 410,
    413, 415, 422 - and is now logged as an error, since the state it carried is
    lost for good.
  • Anything the controller may yet accept keeps the message queued: server
    errors, rate limiting, request timeout, and a refusal to authenticate or
    authorize the device, which EVE itself answers by restarting attestation and
    by fetching new controller certificates. There is no attempt limit; a
    message this queue carries has no other route to the controller, so the only
    alternative to retrying is losing it.

Retrying safely required fixing the queue ordering as well, which is the
larger half of the change. A status code means the controller was reached, so
the failure belongs to that one message: the pass now continues instead of
aborting for every remaining message and every lower-priority class, and a
message waiting out its retry delay moves behind its peers rather than being
retried ahead of them. Without this, converting the discard into a retry would
have traded data loss for one object blocking the reports of all the others.

Because retrying forever must not be silent, a long backlog of undelivered
messages and an individual message the controller keeps refusing are both
raised as warnings. Reporting an object again with unchanged content keeps the
retry state of the queued message, so a device republishing identical bytes
neither restarts the delay nor hides how long a message has been stuck.

Two smaller things fall out of the above. SendOnAllIntf now reports the
sender status of the port that actually delivered a request rather than a
complaint collected from a port that did not, so a delivered report is no
longer kept and sent a second time. And the first edgeview status no longer
takes a special path: it was routed to the best-effort queue in the hope of
getting it delivered, which had the opposite effect, since that queue discards
a message whose send fails.

Fixes #6302

How to test and validate this PR

Covered by automated unit tests in pkg/pillar/controllerconn: 30 tests, run
with make -C pkg/pillar test or go test -race ./controllerconn/.... They
cover the status classification, the per-item delay, the demotion behind peers,
the interaction with the attest / app-info / hardware-info priority classes,
the two warnings, the retry state of a republished payload, and the queue
length staying bounded by the number of objects. Each was checked against the
pre-change behavior: reverting the classification or the pass-continuation
makes them fail on their assertions rather than pass silently.

It is also covered end to end. evetest can put the controller behind a proxy
which answers, delays or drops selected device requests - asked for with
EVETEST_CONTROLLER_FAULTS=true, and out of the path otherwise. Then
evetest/tests/controllerfaults uses that: an application's state is changed
while the controller fails the info messages reporting it, and the controller
then has to catch up on its own. Run with

EVETEST_CONTROLLER_FAULTS=true make evetest NAME=TestControllerFaultsSuite

Without the variable the controller is reached directly and these tests skip.

The outcome is measured rather than argued. TestInfoRetriedAfterServerError
fails against an EVE built without this change - the proxy answered every
info message with 503, so the device did try, and then nothing arrived in the
eight minutes after the controller started accepting messages again, the report
being gone for good - and passes against one built with it. The suite is
green as a whole on amd64 under QEMU:

--- PASS: TestControllerFaultsSuite (897.91s)
    --- PASS: .../TestInfoRetriedAfterServerError (501.83s)
    --- PASS: .../TestInfoRetriedAfterLostController (211.87s)
    --- PASS: .../TestInfoDroppedOnRejectionKeepsDeviceReporting (180.73s)

Only the first of the three distinguishes this change; the other two guard
behavior it deliberately preserves - a controller which cannot be reached keeps
the message queued, and one which rejects it drops the message without leaving
the queue unable to deliver what follows.

Changelog notes

Device state updates - application, volume, content tree, network instance,
hardware and cluster information - are no longer discarded when the controller
answers with a temporary error such as a 503 during an upgrade or an overload.
They are retried until the controller accepts them, so its view of a device no
longer stays stale after a brief interruption. Messages the controller rejects
outright are still discarded, now with an error logged.

PR Backports

The bug is present on all four branches, but the queue-ordering half of the fix
changes behavior in code which has been fragile before, so the backports wait
until this has soaked on master for a while. Review asked for the same.

  • 17.0-stable: after soak on master
  • 16.0-stable: after soak on master
  • 14.5-stable: after soak on master
  • 13.4-stable: after soak on master

Checklist

  • I've provided a proper description

  • I've added the proper documentation

  • I've tested my PR on amd64 device

  • I've tested my PR on arm64 device

  • I've written the test verification instructions

  • I've set the proper labels to this PR

  • I've checked the boxes above, or I've provided a good reason why I didn't
    check them: the tests above ran on an amd64 QEMU device rather than hardware,
    and the arm64 box is unchecked for want of one. Nothing here is architecture
    specific.

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.37209% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 25.06%. Comparing base (181b1c0) to head (a6bff41).

Files with missing lines Patch % Lines
pkg/pillar/cmd/zedagent/zedagent.go 0.00% 6 Missing ⚠️
pkg/pillar/controllerconn/deferred.go 95.53% 4 Missing and 1 partial ⚠️
pkg/pillar/cmd/zedagent/handlecertconfig.go 0.00% 3 Missing ⚠️
pkg/pillar/cmd/zedagent/handlemetrics.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6304      +/-   ##
==========================================
+ Coverage   24.61%   25.06%   +0.45%     
==========================================
  Files         506      516      +10     
  Lines       93048    94839    +1791     
==========================================
+ Hits        22900    23774     +874     
- Misses      68318    69064     +746     
- Partials     1830     2001     +171     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@eriknordmark
eriknordmark force-pushed the deferred-queue-retry-transient branch from 6201ddd to 3eb3394 Compare August 13, 2026 14:26
@github-actions
github-actions Bot requested a review from OhmSpectator August 13, 2026 14:26
@eriknordmark
eriknordmark removed the request for review from OhmSpectator August 13, 2026 14:27
@eriknordmark
eriknordmark marked this pull request as ready for review August 14, 2026 22:04
@rene

rene commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

@claude

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Claude finished @rene's task in 4m 22s —— View job


Review

  • Read core changes (deferred.go, send.go, zedagent)
  • Analyze retry/ordering logic for bugs
  • Review evetest fault-injection additions
  • Post review feedback

I reviewed the production changes (controllerconn/deferred.go, controllerconn/send.go, zedagent) and the evetest additions. The retention-by-status-code design, the per-item backoff + demotion-behind-peers, and the pass-continues-on-status vs. abort-on-no-response split all look sound and well-documented. Two findings, both posted inline:

  1. send.go (delivering-port status) — the new "report the delivering port's status" copy is gated on 2xx only, but SendOnIntf treats 200/201/304/204 as delivered success. A delivered 304 (unchanged config is a real controller response) or 204 following a failed port keeps the stale failure status, which sendItem then treats as a failed send (itemStopPass). Suggested matching the full success set. (medium)

  2. deferred.go (retry-state inheritance) — the "identical bytes keep retry state" guarantee is bypassed when the same key is republished during a handleDeferred pass, because the item is checked out of q.deferredItems and mergeQueuesNoLock replaces it with a fresh (attempts=0) entry. Narrow/racy; either preserve state on merge or soften the doc claim. (minor)

Nothing blocking. The evetest fault proxy and controller-fault tests look reasonable (rules copied under lock, admin API path shielded, ErrAbortHandler used for the drop-connection case).
· branch deferred-queue-retry-transient

Comment on lines +411 to +413
if rv.HTTPResp != nil && rv.HTTPResp.StatusCode >= 200 &&
rv.HTTPResp.StatusCode < 300 {
combinedRV.Status = rv.Status

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "delivered by this port, so report its status" fix only copies rv.Status for 2xx, but SendOnIntf treats 200/201/304/204 as delivered success (line 894 here, and it now sets rv.Status = SenderStatusNone for all of them). 304 Not Modified is a real controller response (unchanged config), and 204 is covered while 304 is not.

So when an earlier port failed (setting combinedRV.Status to e.g. SenderStatusUpgrade from a 503) and a later port delivers a 304, this branch is skipped and combinedRV.Status keeps the stale failure status even though the request was delivered. For the deferred queue that surfaces as sendItem hitting the rv.Status != SenderStatusNone case and treating a successful delivery as a failure (itemStopPass). Consider matching the set SendOnIntf accepts rather than a bare 2xx:

Suggested change
if rv.HTTPResp != nil && rv.HTTPResp.StatusCode >= 200 &&
rv.HTTPResp.StatusCode < 300 {
combinedRV.Status = rv.Status
if rv.HTTPResp != nil && (rv.HTTPResp.StatusCode == http.StatusOK ||
rv.HTTPResp.StatusCode == http.StatusCreated ||
rv.HTTPResp.StatusCode == http.StatusNotModified ||
rv.HTTPResp.StatusCode == http.StatusNoContent) {
combinedRV.Status = rv.Status
}

Comment on lines +560 to +563
if sameContent(q.deferredItems[ind].buf, buf) {
item.attempts = q.deferredItems[ind].attempts
item.retryAt = q.deferredItems[ind].retryAt
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this retry-state inheritance only applies while the item is still in q.deferredItems. During a handleDeferred pass the item is checked out into reqs, so a concurrent SetDeferred for the same key finds no existing entry and adds a fresh item (attempts=0, retryAt zero). mergeQueuesNoLock then replaces the retried notSentReqs entry with that fresh one, dropping its attempts/retryAt.

Net effect: an object republishing identical bytes at the wrong moment can reset its backoff and hide the accumulated attempt count — the case this comment says is preserved. Narrow/racy, but worth either preserving the retry state in mergeQueuesNoLock when sameContent holds, or softening the guarantee here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fresh seems the better behavior - want to give the last update N tries.

// harness keeps talking to Adam directly on the internal port, so reading what
// the controller knows is never affected by an injected fault.
// Must be called before Start.
func (ac *AdamClient) EnableFaultInjection() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it hurt to always run this proxy and have fault injections available for any test?
I mean, if test does not configure any faults, then everything behaves as normally, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it would work, and the cost is small — but so is the benefit: always-on
only saves a suite from calling EnableControllerFaults(). The cost, by
contrast, is paid in every unrelated test's failure analysis - did the proxy (passing through)
cause the test to fail or not?

Would it make sense to move this from a per-suite call to a harness-level env var/flag, defaulted off?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, yes I think EVETEST_<something> variable to enable/disable this would make sense.
I would like to keep evetest.Init() as the first evetest function call always in every test(suite).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - EVETEST_CONTROLLER_FAULTS, off by default:

EVETEST_CONTROLLER_FAULTS=true make evetest NAME=TestControllerFaultsSuite

EnableControllerFaults() is gone, so evetest.Init() is the first evetest call in every test again. Without the variable the devices reach Adam directly and these tests skip, naming the variable in the skip message; the README documents it under Debugging Variables.

With it set, Adam moves to loopback and the proxy takes over the device-facing addresses, so an injected fault cannot be sidestepped by reaching the controller directly:

Adam process started and listening on IPs:[127.0.0.1], port:443
Fault proxy listening on 245.245.245.245:443, forwarding to 127.0.0.1:443
Fault proxy listening on [fd24:1ac2:e355::1]:443, forwarding to 127.0.0.1:443

Both paths were run on amd64 under QEMU after rebasing onto master: with the variable the suite is green (897.91s), and without it TestBootstrapWithProxy still onboards through the SDN's transparent MITM proxy with no fault proxy in the path at all. Note that whatever runs these in CI has to set the variable explicitly, or the coverage silently disappears behind a skip.

@milan-zededa milan-zededa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This enhancement makes sense to me, but given how vulnerable and critical for EVE these deferred queues are, I’d suggest holding off on backporting it until it has been thoroughly tested on master.
Only added one question regarding the Adam proxy.

@eriknordmark

Copy link
Copy Markdown
Contributor Author

This enhancement makes sense to me, but given how vulnerable and critical for EVE these deferred queues are, I’d suggest holding off on backporting it until it has been thoroughly tested on master.

Makes sense. I'll park the backport PRs in draft state with a note.

@eriknordmark
eriknordmark force-pushed the deferred-queue-retry-transient branch from 82abfb8 to 4eb11b8 Compare August 20, 2026 18:47
eriknordmark and others added 6 commits August 20, 2026 21:38
SendOnAllIntf hands back the HTTP response only when a send succeeded, or
when the caller asked to stop at the first port that answered with an error.
When it walks every port and none of them delivers, the status the server
did answer with is lost, leaving the caller unable to tell a rejection by
the controller from a link that never got there. Carry the last status code
seen in the return value so that a caller can base a retry decision on it.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: eriknordmark <erik@zededa.com>
A device reports application, volume, content tree, blob, network instance,
hardware and cluster state to the controller only when that state changes;
nothing re-asserts it later. Yet any status between 400 and 599 made the
deferred queue log one line and then treat the message as if it had been
delivered, so a few seconds of controller-side trouble left the controller's
view of an object wrong until the object next changed - possibly never. The
discard was introduced for a controller that no longer knows about an object
and therefore rejects the request itself; server errors were swept into it
when the same flag started meaning "do not try the other ports".

Decide from the status code instead. A rejection of the request is still
given up on, now logged as an error since the state it carried is lost for
good. Anything the controller may yet accept - a server error, a rate limit,
or a refusal to authorize the device, which EVE answers by restarting
attestation - keeps the message queued and offers it again with a growing
delay. There is no attempt limit: a message this queue carries has no other
path to the controller, so the only alternative to retrying is losing it.

Since a status code means the controller was reached, the failure belongs to
that one message and the rest of the pass now continues, where before a
single refused item aborted the pass for every remaining item and priority
class and was then retried ahead of them all. A message waiting out its
delay moves behind its peers, so one object cannot hold up the reports for
all the others. Failures are reported to the sent-callback with a real
status, so a message the controller refused is no longer recorded as sent.

Retrying without end must not be silent, so a backlog of undelivered
messages and an individual message the controller keeps refusing are both
raised as warnings. Reporting an object again with unchanged content keeps
the retry state of the queued message, so that a device republishing the
same bytes neither restarts the delay nor hides how long the message has
been stuck.

That leaves the two flags with one meaning each: BailOnHTTPErr says how many
ports to try, exactly as in RequestOptions, and DiscardOnFailure - which used
to be called IgnoreErr, having never ignored an error - says that a payload
is superseded by the next periodic one and so is not worth keeping.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first edgeview status, the one that tells the controller edgeview is
active, was routed to the best-effort periodic queue in the hope of getting
it delivered - which had the opposite effect, since that queue discards a
message whose send fails instead of retrying it. It now takes the same path
as every other info message, where a controller-side failure is retried.

That leaves location info as the only user of the periodic queue, so also
correct its description: metrics and hardware health never went through a
deferred queue, and NTP sources deliberately use the reliable one.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: eriknordmark <erik@zededa.com>
While walking the management ports, any non-zero sender status picked up on
the way was kept in the return value, including when a later port went on to
deliver the request. The caller then saw a successful send described by the
complaint of a port that failed - the deferred queue treated such a message
as not sent and kept it queued for another try, so a report that did reach
the controller could be sent a second time, and a config fetch that
succeeded on the second port still logged a controller upgrade.

Report the status of the attempt that produced the response. Within a port
this restores an invariant that already held one branch away: on any
non-2xx response the status collected from the source addresses tried
earlier is already discarded, and only the branch for a delivered response
kept it. Nothing is lost for diagnosis, since every certificate and
connection failure is logged where it happens. When no port delivers the
request the collected status still stands, which is what tells a caller
that the controller rejected the request rather than being unreachable.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: eriknordmark <erik@zededa.com>
Making EVE face a controller which fails in a particular way was not
expressible: the network model can impair a link, but nothing could make the
controller answer a chosen status, and how EVE handles the answers it gets is
exactly what several parts of the device reporting path turn on.

A test suite can now ask for the controller to run behind a proxy which
answers, delays or drops selected device requests, and arm or clear those
faults as the test goes. Only what the devices send is affected: the harness
reaches the controller on its own port, so reading back what the controller
knows is never disturbed by an injected fault, and a rule can only match the
device API, never the admin one. The proxy presents the controller's own
certificate to the devices and verifies the controller's certificate in turn,
so the connection is no less protected than without it.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: eriknordmark <erik@zededa.com>
Application state reaches the controller only when it changes, so whether a
report survives a controller that will not take it decides whether the
controller's view of an application is right or stale indefinitely. Three
tests deploy an application, make the controller fail the info messages that
report it, and change its state while it cannot be reported.

Two require the controller to catch up on its own once it accepts messages
again: one where it answered a server error, one where it could not be reached
at all. Both hold the fault until the state transition has had time to
complete - a report produced after the controller is healthy again would be
delivered whatever the queue does with a refused one, so clearing the fault
early would prove nothing - and then allow for the retry delay the device has
reached by that point.

The third covers a rejection, where the report is deliberately given up on.
The state it carried is never told to the controller, and an application which
has settled produces no further report to carry it, so this one watches the
device information instead: EVE re-sends that on a timer even when nothing
changed, which shows the queue still delivering after messages were dropped,
without depending on how long an application takes to stop.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eriknordmark
eriknordmark force-pushed the deferred-queue-retry-transient branch from 4eb11b8 to 4cceea5 Compare August 20, 2026 19:41
Controller fault injection is asked for with EVETEST_CONTROLLER_FAULTS, a
harness-level switch, which keeps evetest.Init the first evetest call in every
test and lets any test arm a fault. It is off by default: with nothing armed the
proxy forwards every request unchanged, but it terminates TLS and re-originates
the request, so leaving it out of the path unless a test needs it keeps an
unrelated failure one suspect fewer. A test which arms faults skips when it is
off, naming the variable.

With the proxy in front of it, Adam listens on loopback rather than on the
addresses the devices route to, so an injected fault cannot be sidestepped by
reaching the controller directly; the harness verifies the controller's hostname
so that it can still reach Adam at an address the certificate does not name.
Proxy errors reach the harness log rather than the global standard logger, and a
proxy which fails to take its port no longer leaves Adam running behind it,
unreachable and holding its database directory.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eriknordmark
eriknordmark force-pushed the deferred-queue-retry-transient branch from 4cceea5 to a6bff41 Compare August 20, 2026 19:52
@eriknordmark
eriknordmark merged commit bdb81de into lf-edge:master Aug 21, 2026
54 of 56 checks passed
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.

pillar: deferred queue discards info messages on any 4xx/5xx (BailOnHTTPErr means two different things)

3 participants