Skip to content

Cache editor/validate_yaml results per session content hash#892

Merged
bdraco merged 4 commits into
mainfrom
editor-validate-cache
May 17, 2026
Merged

Cache editor/validate_yaml results per session content hash#892
bdraco merged 4 commits into
mainfrom
editor-validate-cache

Conversation

@bdraco
Copy link
Copy Markdown
Member

@bdraco bdraco commented May 16, 2026

What does this implement/fix?

The editor's CodeMirror linter and _saveYaml both call
editor/validate_yaml against the same buffer back-to-back: the
linter at typing-stop+600ms, the save on click. On large configs
with external_components / packages: / !include the
esphome vscode subprocess takes several seconds per pass.
Visible on user reports as 7-9s click-to-popup latency on a
Celeron NUC (MasterOfNone, Slack 2026-05-16).

This PR caches editor/validate_yaml results on _EditorSession
keyed by content hash with a 60s TTL. The save-time re-validate
hits the linter's just-stored result instead of spinning up
another subprocess pass. Different content or expiry invalidates
and re-validates as before. Uses fnv1a_32 from
fnv-hash-fast since this is a cache key, not a security
boundary.

The 60s TTL bounds staleness for !include / external-component
files mutated outside the editor; long enough to cover any
realistic linter-to-save hand-off, short enough that an external
change recovers on the next debounce tick.

Related issue or feature (if applicable):

  • fixes <discord report from MasterOfNone, 2026-05-16>

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

Backend cache makes the save-time validate_yaml cheap; the
frontend companion skips the WS round-trip entirely when the
linter's last result still matches the buffer. The two compose;
either alone is an improvement.

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.

The editor's CodeMirror linter and ``_saveYaml`` both fire
``editor/validate_yaml`` against the same buffer back-to-back
(linter at typing-stop+600ms, save on click). On large configs
with ``external_components`` / ``packages:`` / ``!include`` the
``esphome vscode`` subprocess takes several seconds per pass —
visible as 7-9s click-to-popup latency on a Celeron NUC.

Add a per-session content-hash cache (60s TTL, ``fnv1a_32`` hash
since this is a cache key, not a security boundary). The save-time
re-validate hits the linter's just-stored result instead of
spinning up another subprocess pass. Different content or expiry
invalidates and re-validates as before.

The 60s TTL bounds staleness for ``!include`` / external-component
files mutated outside the editor; long enough to cover any
realistic linter→save hand-off, short enough that an editor-
external change recovers on the next debounce tick.
Copilot AI review requested due to automatic review settings May 16, 2026 21:26
@github-actions github-actions Bot added the enhancement Improvement to an existing feature label May 16, 2026
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 16, 2026

Merging this PR will not alter performance

✅ 25 untouched benchmarks


Comparing editor-validate-cache (b707696) with main (6dc2b53)

Open in CodSpeed

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 16, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.32%. Comparing base (6dc2b53) to head (b707696).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #892   +/-   ##
=======================================
  Coverage   99.31%   99.32%           
=======================================
  Files         190      190           
  Lines       13823    13843   +20     
=======================================
+ Hits        13729    13749   +20     
  Misses         94       94           
Flag Coverage Δ
py3.12 99.27% <100.00%> (+<0.01%) ⬆️
py3.14 99.32% <100.00%> (+<0.01%) ⬆️

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

Files with missing lines Coverage Δ
esphome_device_builder/controllers/editor.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

This PR improves YAML editor responsiveness by caching editor/validate_yaml results per _EditorSession, keyed by a content hash with a 60s TTL, so save-time validation can reuse the linter’s just-computed result instead of re-running the expensive esphome vscode subprocess.

Changes:

  • Add a per-session validate-result cache (content-hash + timestamp) with a 60s TTL in EditorController.validate_yaml.
  • Add fnv-hash-fast dependency to compute a cheap non-cryptographic content hash for cache keys.
  • Add tests covering cache hit/miss behavior, TTL expiry, and per-configuration isolation.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
esphome_device_builder/controllers/editor.py Implements validate-result caching keyed by content hash with TTL.
pyproject.toml Adds fnv-hash-fast dependency for hashing editor content.
tests/test_editor_controller.py Adds unit tests validating cache behavior (repeat content, content change, TTL, config isolation).

Comment thread esphome_device_builder/controllers/editor.py Outdated
@bdraco bdraco marked this pull request as draft May 16, 2026 21:30
…check

* The three ``cached_hash`` / ``cached_result`` / ``cached_at``
  fields on ``_EditorSession`` move together — group them into
  a single ``_CachedValidation`` dataclass with an
  ``is_fresh_for(content_hash)`` predicate. Fewer load-bearing
  invariants spread across the session class.
* Codecov flagged line 226 (the under-lock cache-hit branch)
  as uncovered — every test missed because the fast-path check
  catches them first. New test
  ``test_validate_yaml_inner_lock_recheck_coalesces_concurrent_calls``
  fires two concurrent validate calls against the same content:
  first wins the lock and runs the subprocess, second hits the
  inner re-check and returns the just-populated cache entry.
@bdraco
Copy link
Copy Markdown
Member Author

bdraco commented May 16, 2026

@bluetoothbot review

@bluetoothbot
Copy link
Copy Markdown

PR Review — Cache editor/validate_yaml results per session content hash

Clean, well-scoped change. The atomic-ref-swap pattern in _CachedValidation correctly addresses the previous reviewer's torn-read concern (single attribute read on the fast path, single assignment on the write path — no lock needed for the reader). The double-checked locking on the slow path is correct and prevents duplicate subprocess passes when linter+save race. Test coverage is thorough: hit, miss, TTL expiry, per-configuration isolation, and concurrent-coalescing all pinned. TTL choice is well-justified in the docstring. The two notes above are non-blocking polish suggestions.


🟢 Suggestions

1. 32-bit hash collision is astronomically unlikely but theoretically possible (`esphome_device_builder/controllers/editor.py`, L34-50)

fnv1a_32 returns a 32-bit hash, so two distinct YAML buffers hashing to the same value within the 60s TTL window would cause the second call to return the first one's result for the wrong content. By birthday paradox this needs ~65k distinct contents in flight before reaching ~50% — within a single editor session over 60s you'll see at most a handful, so the practical risk is essentially nil. Worth a one-line acknowledgement in the docstring (or a switch to fnv1a_64 if you'd rather close the gap entirely), but not blocking. The cached result field is also typed dict rather than dict[str, Any] — minor nit for consistency with the rest of the module.

@dataclass
class _CachedValidation:
    content_hash: int
    result: dict
    at: float
2. Consider invalidating the cache when the subprocess is terminated (`esphome_device_builder/controllers/editor.py`, L225-240)

On asyncio.TimeoutError, _terminate_subprocess is called and the error re-raised, but session.cached is left untouched. That's fine in steady state (the previous entry was produced by a successful run), but if the YAML being validated is now triggering wedges that look identical to a previously-cached successful one, a hash-equal cache hit would mask the new failure mode. Probably not worth the complexity unless you've seen it — flagging because the _terminate_subprocess call path is the one place where the session state changes underneath the cache.

except (TimeoutError, asyncio.TimeoutError):
    await self._terminate_subprocess(session)
    raise

Checklist

  • No hardcoded secrets
  • No race conditions on cache read/write
  • Cache is bounded (single entry per session, TTL'd)
  • Error paths don't leak resources or stale state
  • Tests cover new branches (hit, miss, TTL, concurrency, per-config)
  • Tests validate behavior, not implementation text
  • Hash function appropriate for cache-key use (non-cryptographic OK) — suggestion #1

Summary

Clean, well-scoped change. The atomic-ref-swap pattern in _CachedValidation correctly addresses the previous reviewer's torn-read concern (single attribute read on the fast path, single assignment on the write path — no lock needed for the reader). The double-checked locking on the slow path is correct and prevents duplicate subprocess passes when linter+save race. Test coverage is thorough: hit, miss, TTL expiry, per-configuration isolation, and concurrent-coalescing all pinned. TTL choice is well-justified in the docstring. The two notes above are non-blocking polish suggestions.


Automated review by Kōandae9615
84fdd97

bluetoothbot's PR #892 review flagged two non-blocking polish
items:

* ``fnv1a_32`` is non-cryptographic with a ~4-billion-value
  range. Realistic collision exposure inside one 60s TTL window
  is the few dozen buffer versions an editor session produces
  (birthday-paradox risk ≈ 1.7e-8). ``fnv-hash-fast`` only ships
  the 32-bit variant, so a switch would mean another dependency
  for a non-issue. Acknowledge the trade in the TTL comment so
  the next reader doesn't re-litigate the choice.
* ``_CachedValidation.result: dict`` → ``dict[str, Any]`` for
  consistency with the rest of the module.

The reviewer's second suggestion (invalidate cache on subprocess
termination) doesn't materialize with a content-hash key —
byte-identical content gives a byte-identical validate result;
the only escape hatch is hash collision (covered above) or an
external ``!include`` mutation (already bounded by the 60s TTL).
@bdraco
Copy link
Copy Markdown
Member Author

bdraco commented May 16, 2026

Both items addressed in 9c4a7a4:

Suggestion #1 — hash class: fnv-hash-fast only exposes fnv1a_32 (verified dir(fnv_hash_fast)['fnv1a_32', ...]), so switching to fnv1a_64 would mean adding another dependency for the gap. Realistic collision math says no: an editor session sees ≤12 distinct buffer versions across a 60s TTL window, giving birthday-paradox risk ≈ 1.7e-8 — well below the noise floor of single-bit RAM errors over the same window. Added a one-liner to the TTL comment acknowledging the non-cryptographic choice so the next reader doesn't re-litigate. Applied the dictdict[str, Any] type-hint nit.

Suggestion #2 — invalidate on subprocess teardown: The concern only materializes if cache content X matches a previously-validated content X but now triggers a wedge. Content X is keyed by content hash, so byte-identical content gives byte-identical validate behavior; the only escape hatch is a hash collision (covered by #1) or external mutation of !include files (already bounded by the 60s TTL). Skipping the change — extra invalidation would be defensive code for a scenario the design already covers.

Self-audit caught two drifts:
* ``validate_yaml`` docstring used an em-dash inside the cache
  paragraph; CLAUDE.md / memory bans em-dashes.
* ``test_validate_yaml_inner_lock_recheck_coalesces_concurrent_calls``
  had a 6-line docstring re-telling the production-side story.
  Test docstrings should name what the test pins in one sentence.
@bdraco
Copy link
Copy Markdown
Member Author

bdraco commented May 17, 2026

definitely better to work with on heavy configs. still a lot of cpu churn for revalidate on heavy configs, but any improvement there is win. Will need to dig into lvgl more as esphome/esphome#16478 is just the tip of it and we have 0 benchmarks for it.

@bdraco bdraco marked this pull request as ready for review May 17, 2026 02:07
@bdraco bdraco merged commit dbb6947 into main May 17, 2026
14 checks passed
@bdraco bdraco deleted the editor-validate-cache branch May 17, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to an existing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants