Skip to content

Surface per-device build directory size with mtime-gated cache#343

Merged
bdraco merged 9 commits intomainfrom
device-build-size
May 5, 2026
Merged

Surface per-device build directory size with mtime-gated cache#343
bdraco merged 9 commits intomainfrom
device-build-size

Conversation

@bdraco
Copy link
Copy Markdown
Member

@bdraco bdraco commented May 5, 2026

What does this implement/fix?

Adds a build_size_bytes field to Device so the dashboard can show how much disk each device's compile artifacts are eating. ESPHome compiles produce 50 MB to 1 GB+ under .esphome/build/<name>/; surfacing the per-device total lets a user planning a clean-up see which devices are heaviest at a glance. Companion frontend renders it as a drawer row + hidden table column.

The walk that produces the size is heavy + I/O-bound, so it's gated by a freshness pair persisted in the metadata sidecar: (build_size_dir_mtime, build_size_info_mtime). Either half moving counts as stale; both are needed because each catches a class of compile-time changes the other misses (empirical matrix in the helper's module docstring — the short version: ESPHome's write_file_if_changed for build_info.json moves the file's mtime without touching the parent dir on a real recompile, while PlatformIO's intermediate sibling churn moves the parent dir without touching build_info.json).

The walks are managed by a new BuildSizeRefresher class — one persistent worker task that drains a pending set keyed on configuration filename. Pulled out of the controller so the controller doesn't carry the bookkeeping for yet another background job. request(configuration) is sync + side-effect-only; repeated requests coalesce in the set, the worker walks one device at a time so disk I/O stays bounded, and the worker's first iteration runs an initial fleet sweep to pick up CLI-compile drift across the whole catalog.

Refresh triggers (event-driven only, no periodic timer): backend startup (worker's initial sweep) and after every successful firmware job (COMPILE / UPLOAD / INSTALL / CLEAN). CLEAN specifically lets the cached triple drop back to zero — the build dir is wiped so the freshness pair becomes (0, 0) and the worker walks once to clear.

The pure pair-equality short-circuit in refresh_build_size_if_stale makes both "no build dir ever" and "dir was wiped manually" idempotent: (0, 0) == (0, 0) returns None without walking, so the worker doesn't churn on configurations whose build dir never existed. Whole-second mtime precision (int(stat.st_mtime)) so the cache works on filesystems without sub-second mtime support (FAT32 / older NFS / CIFS).

build_size_bytes / build_size_dir_mtime / build_size_info_mtime join ip / expected_config_hash / mac_address in _VOLATILE_DEVICE_METADATA_FIELDS so archive scrubs them — the build tree is wiped on archive and the cached triple would describe a directory that no longer exists.

Related issue or feature (if applicable):

  • fixes

Types of changes

  • Bugfix (non-breaking change which fixes an issue) — bugfix
  • New feature (non-breaking change which adds functionality) — new-feature
  • Enhancement to an existing feature — enhancement
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — breaking-change
  • Refactor (no behaviour change) — refactor
  • Documentation only — docs
  • Maintenance / chore — maintenance
  • CI / workflow change — ci
  • Dependencies bump — dependencies

Frontend coordination

Checklist

  • The code change is tested and works locally.
  • Pre-commit hooks pass (ruff, codespell, yaml/json/python checks).
  • Tests have been added or updated under tests/ where applicable.
  • components.json has not been hand-edited (regenerate via script/sync_components.py if a sync is needed).
  • Architecture-level changes are reflected in docs/ARCHITECTURE.md and/or docs/API.md.

Adds a ``build_size_bytes`` field to ``Device`` that the frontend
shows in the per-device drawer and as a hidden-by-default table
column. The walk that produces the size is heavy + I/O-bound, so
it's gated by a freshness pair persisted in the metadata
sidecar: ``(build_size_dir_mtime, build_size_info_mtime)``. Either
half moving counts as stale; both are needed because each
catches a class of compile-time changes the other misses
(empirical matrix documented in ``helpers/build_size.py``).

The walks are managed by a new ``BuildSizeRefresher`` class —
one persistent worker task that drains a pending set keyed on
configuration filename. ``request(configuration)`` is sync +
side-effect-only; repeated requests coalesce, and the worker
walks one device at a time so disk I/O stays bounded even under
bulk-clean / bulk-delete bursts. Worker's first iteration runs
an initial fleet sweep to pick up CLI-compile drift.

Refresh triggers (event-driven only, no periodic timer): backend
startup (worker's initial sweep) and after every successful
firmware job (COMPILE / UPLOAD / INSTALL / CLEAN). CLEAN
specifically lets the cached triple drop back to zero.

Whole-second mtime precision so the cache works on filesystems
without sub-second mtime support (FAT32 / older NFS / CIFS).

The three new fields join ``ip`` / ``expected_config_hash`` /
``mac_address`` in ``_VOLATILE_DEVICE_METADATA_FIELDS`` so
archive scrubs them.
Copilot AI review requested due to automatic review settings May 5, 2026 18:13
@github-actions github-actions Bot added the new-feature New feature label May 5, 2026
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 5, 2026

Merging this PR will not alter performance

✅ 9 untouched benchmarks


Comparing device-build-size (53a6027) with main (b99ac54)

Open in CodSpeed

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 5, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.67%. Comparing base (b99ac54) to head (53a6027).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #343      +/-   ##
==========================================
+ Coverage   98.63%   98.67%   +0.03%     
==========================================
  Files          48       50       +2     
  Lines        5437     5592     +155     
==========================================
+ Hits         5363     5518     +155     
  Misses         74       74              
Flag Coverage Δ
py3.12 98.60% <99.39%> (+0.02%) ⬆️
py3.14 98.67% <100.00%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...evice_builder/controllers/_build_size_refresher.py 100.00% <100.00%> (ø)
...home_device_builder/controllers/_device_scanner.py 100.00% <100.00%> (ø)
esphome_device_builder/controllers/config.py 97.22% <100.00%> (-0.03%) ⬇️
...e_device_builder/controllers/devices/controller.py 99.87% <100.00%> (+<0.01%) ⬆️
esphome_device_builder/helpers/build_size.py 100.00% <100.00%> (ø)
esphome_device_builder/helpers/device_yaml.py 99.61% <ø> (ø)
esphome_device_builder/models/devices.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds backend support for surfacing and caching per-device ESPHome build directory size so the dashboard can display disk usage without triggering expensive recursive walks on every reload.

Changes:

  • Introduces a cached build-size computation (helpers/build_size.py) keyed by a persisted freshness pair (dir_mtime, build_info.json mtime).
  • Adds a single-worker BuildSizeRefresher to serialize refresh I/O, coalesce requests, and perform a startup fleet sweep.
  • Threads build_size_bytes through device metadata, scanning/loading, archive scrubbing, and firmware-job completion hooks; adds/updates tests accordingly.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/test_config_controller.py Adds coverage for persisting/clearing the build-size metadata triple.
tests/test_build_size.py New unit tests covering build-size signal, caching behavior, and stale detection helpers.
tests/controllers/firmware/test_refresh.py Updates firmware job completion tests to assert build-size refresher interaction (esp. CLEAN).
tests/controllers/devices/test_archive.py Ensures archive flow scrubs the new volatile build-size metadata fields.
esphome_device_builder/models/devices.py Adds Device.build_size_bytes to the device model for WS/dashboard rendering.
esphome_device_builder/helpers/device_yaml.py Threads cached build_size_bytes into Device construction via the scanner.
esphome_device_builder/helpers/build_size.py Implements build-dir resolution, freshness signal, stale detection, and cached refresh logic.
esphome_device_builder/controllers/devices/controller.py Wires in BuildSizeRefresher, persists/loads build size, and triggers refreshes after firmware jobs.
esphome_device_builder/controllers/config.py Extends metadata sidecar setters and volatile-field scrubbing to include build-size fields.
esphome_device_builder/controllers/_device_scanner.py Extends DeviceFileMetadata and scanner load path to carry build_size_bytes.
esphome_device_builder/controllers/_build_size_refresher.py New serialized worker that performs startup sweep and drains per-device refresh requests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread esphome_device_builder/helpers/build_size.py Outdated
Comment thread esphome_device_builder/helpers/build_size.py
Comment thread esphome_device_builder/helpers/device_yaml.py Outdated
Comment thread esphome_device_builder/models/devices.py Outdated
Comment thread esphome_device_builder/controllers/devices/controller.py Outdated
Comment thread esphome_device_builder/controllers/_build_size_refresher.py Outdated
Comment thread tests/test_build_size.py Outdated
bdraco added 2 commits May 5, 2026 13:24
BuildDirSignal(dir_mtime, info_mtime) and
BuildSizeRefreshResult(size_bytes, signal) replace the bare 2/3-tuples
returned by get_build_dir_signal and refresh_build_size_if_stale.
Frozen + slotted because both types are pure value objects.
- find_stale_build_dirs no longer skips dir-vanished cache cleanup
- Load metadata file once per fleet sweep instead of per-device
- BuildSizeRefresher.stop() logs unexpected worker exceptions
- Doc-drift fixes across Device.build_size_bytes,
  load_device_from_storage, controller comment, test docstring
- set_device_metadata flattened from a 19-branch wall to a
  tri-state field loop (PLR0912 fix)
- New tests/controllers/test_build_size_refresher.py covers the
  worker pipeline end-to-end with proper asyncio.Event
  synchronization (no sleep-poll loops); patch coverage on
  _build_size_refresher.py is 100%
bdraco and others added 2 commits May 5, 2026 13:41
Codecov flagged line 1862 (the return after
if job_type not in (JobType.COMPILE, JobType.UPLOAD,
JobType.INSTALL): return) as a patch-coverage gap on PR #343.

The existing test_reset_build_env_does_not_schedule_refresh
uses configuration="" which bails earlier at the
empty-configuration short-circuit, so the unhandled-type
branch was never exercised after the CLEAN dispatch landed.
Adding a sibling test that passes a populated configuration
with RESET_BUILD_ENV walks the full dispatch table and
hits the type-not-in-{COMPILE,UPLOAD,INSTALL} fall-through.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread esphome_device_builder/controllers/devices/controller.py
Comment thread esphome_device_builder/controllers/devices/controller.py Outdated
Comment thread esphome_device_builder/helpers/build_size.py Outdated
- ``BuildSizeRefresher.RefreshedCallback`` typed
  ``Callable[[str], Awaitable[Any]]`` so the existing
  ``scanner.reload(...) -> bool`` wires directly without a
  ``-> None`` adapter wrapper. Runtime semantics unchanged
  (return value still ignored); the type annotation just no
  longer claims the callback can't return anything.

- ``_resolve_device_metadata`` wraps the ``int(build_size_bytes)``
  coercion in a ``try/except (TypeError, ValueError)``. A
  hand-edited or partially-written sidecar entry could land here
  with a non-numeric value (None, an object, a decimal-string).
  The metadata resolver runs per-device on every scan; a single
  corrupt entry shouldn't fail the whole scan — fall back to
  the same ``0`` sentinel ``set_device_metadata`` writes when
  clearing, and let the next ``BuildSizeRefresher`` pass walk
  fresh values into the sidecar. Parametric regression test
  pins the four shapes Copilot called out (``None`` /
  non-hex string / ``{}`` / list / decimal-string).

- ``find_stale_build_dirs`` docstring now says "current ≠ cached"
  in both directions instead of "(and a build dir exists)" — the
  helper intentionally returns filenames where the build dir
  vanished but cache had non-zero values, so the worker can
  clear stale cached state.

- DRY: collapsed ``_stat_int_mtime`` into the existing
  ``get_build_dir_mtime`` (renamed parameter ``build_dir`` →
  ``path``). The two helpers were the same function on
  different paths; ``get_build_dir_signal`` now calls one
  helper for both halves of the freshness pair.
Comment thread esphome_device_builder/helpers/build_size.py Outdated
``Path.rglob(\"*\")`` allocates a fresh ``Path`` per entry and
re-stats for ``is_file()``, which roughly doubles the syscall
count on big build trees — PlatformIO checkouts can land at
10k+ files. ``os.walk()`` delegates to ``os.scandir()`` since
Python 3.5, which gets cached ``d_type`` from ``readdir()`` so
"is this a file or a dir" is free.

Pure file-size sum stays a single ``os.path.getsize(...)`` per
file (one ``stat`` syscall, unavoidable). The win is no longer
needing a *second* syscall per directory entry just to type-
check it.

Test that pinned the per-entry-error swallow now patches
``os.path.getsize`` instead of ``Path.stat`` since that's the
syscall the new path actually makes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread esphome_device_builder/helpers/build_size.py
Comment thread esphome_device_builder/helpers/build_size.py Outdated
Copilot flagged that ``find_stale_build_dirs`` /
``refresh_build_size_if_stale`` were calling ``int(... or 0)``
on cached mtimes — a hand-edited or partially-written sidecar
entry like ``\"build_size_dir_mtime\": \"12.7\"`` would raise
``ValueError`` on the fleet sweep / refresh hot path.

Extracted the controller's existing defensive ``int()``
fallback into a public ``coerce_sidecar_int`` helper next to
the other build-size primitives. Same fall-through shape (``0``
on any ``TypeError`` / ``ValueError``), now used at all three
sidecar-int read sites:

  - ``_resolve_device_metadata.build_size_bytes`` (already had
    a try/except — replaced with the helper)
  - ``find_stale_build_dirs`` cached mtime pair
  - ``refresh_build_size_if_stale`` cached mtime pair

Parametric regression test pins the falls-through shapes
(``None`` / ``""`` / non-numeric / decimal-string / ``{}`` /
``[]``) and the round-trip cases (int passthrough, numeric
string).

Copilot's first comment about the double-stat in
``compute_build_dir_size`` was reviewing the pre-``os.walk``
version; the rewrite already collapsed it to one syscall per
file.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread esphome_device_builder/helpers/build_size.py Outdated
Comment thread esphome_device_builder/controllers/_build_size_refresher.py
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@bdraco bdraco merged commit 804a7f5 into main May 5, 2026
12 checks passed
@bdraco bdraco deleted the device-build-size branch May 5, 2026 19:22
@bdraco bdraco mentioned this pull request May 5, 2026
16 tasks
bdraco added a commit that referenced this pull request May 5, 2026
Conflicts: ``set_device_metadata`` and ``_VOLATILE_DEVICE_METADATA_FIELDS``
in ``controllers/config.py`` (build-size triple from #343 vs
regen-failure pair); the corresponding archive + config tests.

Resolved by combining the new fields — both touch the same
sidecar shape and the tri-state field loop, so the conflicts
were textual rather than semantic.

Address Copilot follow-ups:

* Move the cross-restart guard's ``stat()`` + metadata-sidecar
  read off the synchronous schedule path. The schedule call
  itself stays sync (the duplicate-pending and in-memory
  failed-set checks are O(1) set lookups), but the disk-touching
  check now runs inside ``_run()`` via
  ``_regen_already_failed_recently_async`` so a fleet-wide
  cold-start doesn't hand the event loop a long string of
  blocking reads.
* Clamp negative stamp ages to zero. A future-dated
  ``regen_failed_at`` (system clock moved backwards, NTP step,
  hand-edited sidecar value in the future) used to give
  ``time.time() - cached_at`` a negative result — happened to
  also be < TTL, so the guard skipped, but only by accident of
  float math. ``max(0.0, ...)`` makes the contract explicit.
* Fix stale ``_REGEN_FAILURE_TTL`` reference in the docstring —
  the constant is ``_REGEN_FAILURE_TTL_SECONDS``.
* Tighten the ``set_device_metadata`` docstring to call out
  that the regen-failure stamp is a *pair* — both halves are
  written together, both halves should be cleared together;
  the per-field loop just makes that mechanically possible.
* Pin the negative-age clamp behaviour with a regression test.
bdraco added a commit that referenced this pull request May 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new-feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants