What
_run_data_loop's iteration_count is incremented as the last statement of the try body, so
any exception inside that body skips the increment. The counter gates the periodic steps, so a
persistent failure does not merely fail — it changes the cadence of everything gated on it.
Measured at source by AST, at c437f7fa:
| fact |
value |
try statement |
service/data_service.py:940 |
| its body |
lines 942..1448 |
| last statement of the body |
iteration_count += 1, line 1448 |
| handler |
except Exception, line 1449 |
iteration_count = 0 |
line 928 |
The steps gated on the counter: if ENABLE_XVB and iteration_count % 10 == 0 (1399),
if self.wallet_client is not None and iteration_count % 10 == 0 (1419, calling _sync_payouts at
1420), and if self.tari_wallet_client is not None and iteration_count % 10 == 0 (1424, calling
_sync_tari_payouts at 1425). _sync_prices at 1446 is ungated but sits below all of them.
Why the shape matters
A raise at, say, line 1420 does three things, and only the first is intended:
- the handler logs and the loop continues — the designed degradation;
- lines 1425 and 1446 are skipped, so a payout failure silently suppresses the Tari payout sync
and the price sync, which have nothing to do with it; and
- line 1448 is skipped, so
iteration_count stays on the same value.
Point 3 is the one worth the issue. The counter is frozen at a multiple of 10, which is exactly the
value that satisfies % 10 == 0. So the next poll runs the failing step again, and the one after
that, and so on: a step designed to run one poll in ten now runs on every poll, retrying a
persistent failure at ten times its intended rate and never backing off. The failure mode inverts
the throttle it was given.
This is triggered by any exception in that body, not by any particular caller. It is latent
rather than active — I am not claiming a live path reaches it today.
Relationship to #1592
#1592 is where I found it: a malformed monerod/wallet body raised out of _sync_payouts at 1420
and hit exactly this. That path is fixed — the PR for #1592 makes those clients return None
and [] instead of raising, so the specific trigger is gone. The counter's placement is not,
and it is the general case, so it is filed on its own rather than in passing. The #1592 fix does
not depend on this one and this one does not depend on it.
Not claimed / not verified
Shapes worth considering — none of them ruled
finally. Moving the increment to a finally makes the cadence monotonic regardless of the
outcome. Simplest, and it makes the counter mean "polls attempted" rather than "polls completed".
- Increment first. Same effect, and it reads more plainly, but it changes which poll number the
first iteration gets — the gates are % 10 == 0, so the phase shifts, which is visible in
behaviour and needs the tests read before it is chosen.
- Per-step guards. Wrapping each gated step in its own
try stops one step's failure from
skipping its unrelated siblings (point 2 above). This is orthogonal to the counter and could be
taken separately.
A note on the third: the codebase already has an opinion here. data_service.py:116 records that
the interval gates should be phrased as now - last >= N rather than iteration_count % k, because
the modulo form "silently changes cadence if UPDATE_INTERVAL" moves. This defect is that same
sentence arriving by a different route — the modulo form silently changes cadence when the counter
stops advancing. Whoever takes this should read that comment first; it may be that the honest fix is
the one it already recommends.
What
_run_data_loop'siteration_countis incremented as the last statement of thetrybody, soany exception inside that body skips the increment. The counter gates the periodic steps, so a
persistent failure does not merely fail — it changes the cadence of everything gated on it.
Measured at source by AST, at
c437f7fa:trystatementservice/data_service.py:940iteration_count += 1, line 1448except Exception, line 1449iteration_count = 0The steps gated on the counter:
if ENABLE_XVB and iteration_count % 10 == 0(1399),if self.wallet_client is not None and iteration_count % 10 == 0(1419, calling_sync_payoutsat1420), and
if self.tari_wallet_client is not None and iteration_count % 10 == 0(1424, calling_sync_tari_payoutsat 1425)._sync_pricesat 1446 is ungated but sits below all of them.Why the shape matters
A raise at, say, line 1420 does three things, and only the first is intended:
and the price sync, which have nothing to do with it; and
iteration_countstays on the same value.Point 3 is the one worth the issue. The counter is frozen at a multiple of 10, which is exactly the
value that satisfies
% 10 == 0. So the next poll runs the failing step again, and the one afterthat, and so on: a step designed to run one poll in ten now runs on every poll, retrying a
persistent failure at ten times its intended rate and never backing off. The failure mode inverts
the throttle it was given.
This is triggered by any exception in that body, not by any particular caller. It is latent
rather than active — I am not claiming a live path reaches it today.
Relationship to #1592
#1592 is where I found it: a malformed monerod/wallet body raised out of
_sync_payoutsat 1420and hit exactly this. That path is fixed — the PR for #1592 makes those clients return
Noneand
[]instead of raising, so the specific trigger is gone. The counter's placement is not,and it is the general case, so it is filed on its own rather than in passing. The #1592 fix does
not depend on this one and this one does not depend on it.
Not claimed / not verified
dict | Nonecontract, and an array body raises instead of returning None #1592's was the one I found, and it is closed.reproduction. Anyone taking it should reproduce before choosing between the shapes below.
module_body/nonlocalinteractions were not examined;iteration_countis a plain local of_run_data_loop.Shapes worth considering — none of them ruled
finally. Moving the increment to afinallymakes the cadence monotonic regardless of theoutcome. Simplest, and it makes the counter mean "polls attempted" rather than "polls completed".
first iteration gets — the gates are
% 10 == 0, so the phase shifts, which is visible inbehaviour and needs the tests read before it is chosen.
trystops one step's failure fromskipping its unrelated siblings (point 2 above). This is orthogonal to the counter and could be
taken separately.
A note on the third: the codebase already has an opinion here.
data_service.py:116records thatthe interval gates should be phrased as
now - last >= Nrather thaniteration_count % k, becausethe modulo form "silently changes cadence if UPDATE_INTERVAL" moves. This defect is that same
sentence arriving by a different route — the modulo form silently changes cadence when the counter
stops advancing. Whoever takes this should read that comment first; it may be that the honest fix is
the one it already recommends.