Skip to content

perf: cut avoidable SD writes and per-cycle log volume - #468

Closed
ChuckBuilds wants to merge 2 commits into
mainfrom
perf/sd-wear-and-log-volume
Closed

perf: cut avoidable SD writes and per-cycle log volume#468
ChuckBuilds wants to merge 2 commits into
mainfrom
perf/sd-wear-and-log-volume

Conversation

@ChuckBuilds

Copy link
Copy Markdown
Owner

Consolidates #466 and #467 into one review, to spare CodeRabbit two passes. Both commits are unchanged and cherry-picked with -x; the originals will be closed pointing here.

Two independent findings from measuring a live rig. They touch disjoint files — plugin_health.py for one, plugin_adapter.py/scroll_helper.py for the other — so they can be reviewed separately even though they arrive together.


1. A health record was rewritten on every healthy cycle

record_success() fires on every successful plugin update and persisted unconditionally. In steady state the only changed fields were total_successes and last_success_time — a counter and a timestamp that health_monitor shows and that nothing reads back after a restart.

Measured on a rig with 24 plugins, all steady-state: a five-minute sample caught 22 rewrites, ~4.4/min, ~6,300/day.

I originally reported this as 25,000/day from a 45-second sample. That was a burst, not the steady state — the rate falls as the window lengthens (17/min at 45s → 6.5/min at 120s → 4.4/min at 300s). The commit and PR were corrected before consolidation. It is a smaller win than I first claimed, and still worth having on a device whose cards keep dying.

Each write is ~400 bytes through cache_manager.set(), which writes a file per call — so a filesystem block plus an ext4 journal write. On SD the unit of cost is an erase-block cycle, not bytes.

Persisting is now limited to the four fields the circuit breaker is rebuilt from. Failures, circuit openings and recoveries still write immediately; in-memory state is untouched, so the health API and UI are unchanged.

Checked and deliberately not changed: plugin_metrics:* churns at the same cadence and looked like the same fix. It isn't — the web service builds its own PluginResourceMonitor over the shared cache (app.py:189), so that write is the cross-process transport, and per plugin it already writes exactly once per cycle. Already at its floor.

2. The Vegas content path traced at INFO

13,408 log lines/hour — 13,366 INFO, 35 WARNING, 5 ERROR. ~223 lines/min of string formatting on a Pi that is also driving the panel, written through journald to the SD card, burying the 35 lines that mean something.

399  [plugin] --> INCLUDED in Vegas scroll
195  [plugin] Has get_vegas_content: True
168  [plugin] Native: get_vegas_content() returned None
168  [plugin] Native content returned None      <- same fact, twice

54 logger.infologger.debug in plugin_adapter, plus the per-frame scroll-progress line. 13,408 → 10,234 lines/hour, a 23% cut. Level change only.

The 19 warning/error/exception calls are untouched. One INFO call is deliberate and stays — the padding-strip message picks its level at runtime and test_vegas_plugin_adapter.py pins it; I found that test before making the change, not after.


Verification

599 tests pass across the health, vegas and scroll suites on the combined branch.

Mutation-checked, four ways:

mutation result
persist health unconditionally again steady-state test fails
widen _DURABLE_FIELDS to include last_success_time steady-state test fails
reintroduce one INFO trace trace guard fails
demote warning/error along with the trace error-reporting guard fails

🤖 Generated with Claude Code

https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

Every successful plugin update called record_success(), which persisted the
record unconditionally. In steady state the only fields that had changed were
total_successes and last_success_time -- a counter and a timestamp that
health_monitor surfaces for display and that nothing reads back after a
restart. Nothing alerts on the age of last_successful_update; it is carried in
the metrics dataclass and shown.

Measured on a rig running 24 plugins, all steady-state (0 consecutive
failures, circuit closed): a five-minute sample caught 22 health-file
rewrites, about 4.4 a minute or 6,300 a day. Each write is ~400 bytes through
cache_manager.set(), which writes a file per call, so each one costs a
filesystem block plus an ext4 journal write.

That lands on an SD card, where the unit of cost is an erase-block cycle
rather than the bytes involved, and where wear is what eventually kills the
card. Two cards have already failed on the other rig with the same
signature -- unreadable block device, EIO on exec, sshd unable to read its
host keys.

The circuit breaker still has to survive a restart, so the write is kept for
exactly the fields it is rebuilt from: consecutive_failures, circuit_state,
circuit_opened_time, half_open_start_time. A failure, a circuit opening and a
recovery are all still written the moment they happen. In-memory state is
updated every time either way, so the health API and web UI show what they
always did.

Tested: 100 healthy cycles now perform zero writes after the first, the
counters remain accurate in memory, and a failure, a recovery and a
half-open-to-closed transition each still reach disk. One test kills and
rebuilds the tracker from the cache to prove the breaker's state genuinely
survives what is no longer written.

Mutation-checked both ways: persisting unconditionally again fails the
steady-state test, and widening _DURABLE_FIELDS to include last_success_time
fails it too. The 46 existing health tests pass.

(cherry picked from commit 14abea2)
plugin_adapter narrates every step of acquiring content from every plugin --
"Has get_vegas_content", "Native: calling get_vegas_content()", "Native
content returned None", "Has scroll_helper", per-item sizes -- once per plugin
per cycle, all at INFO.

Measured on a live rig: 13,408 log lines an hour, of which 13,366 were INFO
and 35 were WARNING. Roughly 223 lines a minute of string formatting on a Pi
that is also driving the panel, written through journald to the SD card, with
the 35 lines that actually indicate a problem buried among them.

Top repeated messages in that hour:

    717  Scroll progress: elapsed=... total_scrolled=.../... px
    399  [plugin] --> INCLUDED in Vegas scroll
    323  [plugin] content_type=static, display_mode=fixed
    195  [plugin] Has get_vegas_content: True
    195  [plugin] Native: calling get_vegas_content()
    168  [plugin] Native: get_vegas_content() returned None
    168  [plugin] Native content returned None        <- the same fact, twice

54 logger.info calls in plugin_adapter become logger.debug, along with the
per-frame scroll-progress line in scroll_helper. Together those are 3,174 of
the 13,408 lines an hour, a 23% cut, and the ~3,600 odds-manager lines are
addressed separately by ledmatrix-plugins#300.

Nothing is lost: the 19 warning/error/exception calls in the module are
untouched, so real failures still surface at their own level. This is a
logging-level change only -- no control flow, no behaviour.

One INFO call is deliberate and stays. The padding-strip message picks its
level at runtime (`logger.warning if (left and right) else logger.info`) and
test_vegas_plugin_adapter.py pins that choice; it survives because it is not a
direct logger.info call site. That test still passes.

Mutation-checked both ways: reintroducing a single INFO trace fails the guard,
and demoting the warning/error calls along with the trace fails a second guard
written for exactly that mistake. 537 vegas and scroll tests pass.

(cherry picked from commit e496d95)
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@ChuckBuilds, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 22f84b0e-8ead-4a4d-bde7-b41e05073734

📥 Commits

Reviewing files that changed from the base of the PR and between cf0a551 and 8d1e43c.

📒 Files selected for processing (5)
  • src/common/scroll_helper.py
  • src/plugin_system/plugin_health.py
  • src/vegas_mode/plugin_adapter.py
  • test/test_health_write_churn.py
  • test/test_vegas_log_volume.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity · 0 duplication

Metric Results
Complexity 0
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@ChuckBuilds

Copy link
Copy Markdown
Owner Author

Superseded by #475, which carries both commits unchanged (cherry-picked with -x) alongside the journald severity fix. Consolidated because CodeRabbit is rate-limiting across the queue. No work lost.

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.

1 participant