Skip to content

Commit dbb6947

Browse files
authored
Cache editor/validate_yaml results per session content hash (esphome#892)
1 parent 58dfcdd commit dbb6947

3 files changed

Lines changed: 189 additions & 2 deletions

File tree

‎esphome_device_builder/controllers/editor.py‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99

1010
import asyncio
1111
import logging
12+
import time
1213
from dataclasses import dataclass, field
1314
from pathlib import Path
1415
from typing import TYPE_CHECKING, Any
1516

17+
from fnv_hash_fast import fnv1a_32
18+
1619
from ..helpers.api import api_command
1720
from ..helpers.json import JSONDecodeError, dumps, loads
1821
from ..helpers.process import kill_quietly
@@ -25,15 +28,38 @@
2528
_LOGGER = logging.getLogger(__name__)
2629
_STARTUP_TIMEOUT = 15.0
2730
_VALIDATE_TIMEOUT = 30.0
31+
# Linter and save both call ``validate_yaml`` on identical
32+
# content (typing-stops → linter at 600 ms → user clicks save).
33+
# Cache covers that hand-off; longer would risk staleness for
34+
# ``!include`` / ``external_components`` files mutated outside
35+
# the editor. ``fnv1a_32`` keys the cache (non-cryptographic;
36+
# collision risk negligible for the ≤dozens of buffers an editor
37+
# session sees inside one TTL window).
38+
_VALIDATE_CACHE_TTL = 60.0
39+
40+
41+
@dataclass
42+
class _CachedValidation:
43+
"""Snapshot of a validate_yaml result, with the inputs needed to reuse it."""
44+
45+
content_hash: int
46+
result: dict[str, Any]
47+
at: float
48+
49+
def is_fresh_for(self, content_hash: int) -> bool:
50+
return (
51+
self.content_hash == content_hash and time.monotonic() - self.at < _VALIDATE_CACHE_TTL
52+
)
2853

2954

3055
@dataclass
3156
class _EditorSession:
32-
"""Per-configuration validator state: one warm subprocess plus a serialization lock."""
57+
"""Per-configuration validator state: warm subprocess, lock, and result cache."""
3358

3459
configuration: str
3560
proc: asyncio.subprocess.Process | None = None
3661
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
62+
cached: _CachedValidation | None = None
3763

3864

3965
class EditorController:
@@ -180,20 +206,39 @@ async def validate_yaml(
180206
the same shape upstream ``vscode.py`` produces. Each error has a
181207
``message`` and (for validation errors) a ``range`` with
182208
``{start_line, start_col, end_line, end_col}`` (0-indexed).
209+
210+
Results are cached per session by content hash for
211+
``_VALIDATE_CACHE_TTL`` seconds; the linter and the
212+
save-time re-validate hit the same content back-to-back.
183213
"""
184214
session = self._sessions.setdefault(
185215
configuration, _EditorSession(configuration=configuration)
186216
)
217+
content_hash = fnv1a_32(content.encode("utf-8"))
218+
# Fast path: avoid the lock when the previous result is
219+
# still fresh for the same content.
220+
cached = session.cached
221+
if cached is not None and cached.is_fresh_for(content_hash):
222+
return cached.result
187223
async with session.lock:
224+
# Re-check under the lock so a concurrent linter+save
225+
# for the same content only spawns one subprocess pass.
226+
cached = session.cached
227+
if cached is not None and cached.is_fresh_for(content_hash):
228+
return cached.result
188229
try:
189-
return await asyncio.wait_for(
230+
result = await asyncio.wait_for(
190231
self._validate_locked(session, configuration, content),
191232
timeout=_VALIDATE_TIMEOUT,
192233
)
193234
except (TimeoutError, RuntimeError, BrokenPipeError):
194235
# Subprocess wedged or died — kill it so the next call respawns.
195236
await self._terminate_subprocess(session)
196237
raise
238+
session.cached = _CachedValidation(
239+
content_hash=content_hash, result=result, at=time.monotonic()
240+
)
241+
return result
197242

198243
async def _validate_locked(
199244
self, session: _EditorSession, configuration: str, content: str

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ dependencies = [
2222
"aiohttp-asyncmdnsresolver>=0.1.1",
2323
"colorlog>=6.8.0",
2424
"cryptography>=42.0.2",
25+
# Cheap non-cryptographic hash for the editor's
26+
# validate-result cache (content → cached subprocess result).
27+
"fnv-hash-fast>=1.0.0",
2528
"ifaddr>=0.2.0",
2629
"mashumaro>=3.13",
2730
"orjson>=3.9.0",

‎tests/test_editor_controller.py‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
import pytest
3838

39+
from esphome_device_builder.controllers import editor as editor_module
3940
from esphome_device_builder.controllers.editor import (
4041
EditorController,
4142
_EditorSession,
@@ -799,3 +800,141 @@ async def _raise_runtime(*_args: Any, **_kwargs: Any) -> dict:
799800
await controller.validate_yaml(configuration="kitchen.yaml", content="")
800801

801802
terminated.assert_awaited_once()
803+
804+
805+
# ---------------------------------------------------------------------------
806+
# validate_yaml — content-hash cache
807+
# ---------------------------------------------------------------------------
808+
809+
810+
@pytest.mark.asyncio
811+
async def test_validate_yaml_caches_result_for_repeated_content(tmp_path: Path) -> None:
812+
"""A second call with identical content returns the cached result.
813+
814+
The linter and the save-time re-validate hit the same buffer
815+
back-to-back; the cache turns the second one into a no-op
816+
instead of paying for another ``esphome vscode`` round-trip.
817+
"""
818+
controller = _make_controller(tmp_path)
819+
calls: list[str] = []
820+
821+
async def _record(session: Any, configuration: str, content: str) -> dict:
822+
calls.append(content)
823+
return {"yaml_errors": [], "validation_errors": []}
824+
825+
controller._validate_locked = _record # type: ignore[method-assign]
826+
827+
first = await controller.validate_yaml(
828+
configuration="kitchen.yaml", content="esphome:\n name: kitchen\n"
829+
)
830+
second = await controller.validate_yaml(
831+
configuration="kitchen.yaml", content="esphome:\n name: kitchen\n"
832+
)
833+
834+
assert first == second == {"yaml_errors": [], "validation_errors": []}
835+
assert len(calls) == 1
836+
837+
838+
@pytest.mark.asyncio
839+
async def test_validate_yaml_cache_misses_on_different_content(tmp_path: Path) -> None:
840+
"""Distinct content bypasses the cache and re-validates."""
841+
controller = _make_controller(tmp_path)
842+
calls: list[str] = []
843+
844+
async def _record(session: Any, configuration: str, content: str) -> dict:
845+
calls.append(content)
846+
return {"yaml_errors": [], "validation_errors": []}
847+
848+
controller._validate_locked = _record # type: ignore[method-assign]
849+
850+
await controller.validate_yaml(
851+
configuration="kitchen.yaml", content="esphome:\n name: kitchen\n"
852+
)
853+
await controller.validate_yaml(
854+
configuration="kitchen.yaml", content="esphome:\n name: kitchen-2\n"
855+
)
856+
857+
assert calls == ["esphome:\n name: kitchen\n", "esphome:\n name: kitchen-2\n"]
858+
859+
860+
@pytest.mark.asyncio
861+
async def test_validate_yaml_cache_expires_after_ttl(
862+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
863+
) -> None:
864+
"""The cache TTL bounds staleness of ``!include`` / external-component reads."""
865+
controller = _make_controller(tmp_path)
866+
calls: list[str] = []
867+
868+
async def _record(session: Any, configuration: str, content: str) -> dict:
869+
calls.append(content)
870+
return {"yaml_errors": [], "validation_errors": []}
871+
872+
controller._validate_locked = _record # type: ignore[method-assign]
873+
fake_now = {"t": 1000.0}
874+
monkeypatch.setattr(editor_module.time, "monotonic", lambda: fake_now["t"])
875+
876+
await controller.validate_yaml(
877+
configuration="kitchen.yaml", content="esphome:\n name: kitchen\n"
878+
)
879+
fake_now["t"] += editor_module._VALIDATE_CACHE_TTL + 1
880+
await controller.validate_yaml(
881+
configuration="kitchen.yaml", content="esphome:\n name: kitchen\n"
882+
)
883+
884+
assert len(calls) == 2
885+
886+
887+
@pytest.mark.asyncio
888+
async def test_validate_yaml_cache_is_per_configuration(tmp_path: Path) -> None:
889+
"""Same content under a different configuration doesn't share the cache."""
890+
controller = _make_controller(tmp_path)
891+
calls: list[tuple[str, str]] = []
892+
893+
async def _record(session: Any, configuration: str, content: str) -> dict:
894+
calls.append((configuration, content))
895+
return {"yaml_errors": [], "validation_errors": []}
896+
897+
controller._validate_locked = _record # type: ignore[method-assign]
898+
899+
await controller.validate_yaml(configuration="kitchen.yaml", content="esphome:\n name: same\n")
900+
await controller.validate_yaml(configuration="bedroom.yaml", content="esphome:\n name: same\n")
901+
902+
assert len(calls) == 2
903+
assert {c[0] for c in calls} == {"kitchen.yaml", "bedroom.yaml"}
904+
905+
906+
@pytest.mark.asyncio
907+
async def test_validate_yaml_inner_lock_recheck_coalesces_concurrent_calls(
908+
tmp_path: Path,
909+
) -> None:
910+
"""Two concurrent calls for identical content share a single subprocess pass."""
911+
controller = _make_controller(tmp_path)
912+
started = asyncio.Event()
913+
release = asyncio.Event()
914+
calls = 0
915+
916+
async def _slow_validate(_session: Any, _configuration: str, _content: str) -> dict:
917+
nonlocal calls
918+
calls += 1
919+
started.set()
920+
await release.wait()
921+
return {"yaml_errors": [], "validation_errors": []}
922+
923+
controller._validate_locked = _slow_validate # type: ignore[method-assign]
924+
925+
first = asyncio.create_task(
926+
controller.validate_yaml(configuration="kitchen.yaml", content="esphome:\n")
927+
)
928+
# Wait for the first call to enter ``_validate_locked`` so the
929+
# second call lands in the fast-path miss + lock-wait window.
930+
await started.wait()
931+
second = asyncio.create_task(
932+
controller.validate_yaml(configuration="kitchen.yaml", content="esphome:\n")
933+
)
934+
# Hand the second task a tick to reach the lock acquire.
935+
await asyncio.sleep(0)
936+
release.set()
937+
results = await asyncio.gather(first, second)
938+
939+
assert calls == 1
940+
assert results[0] == results[1] == {"yaml_errors": [], "validation_errors": []}

0 commit comments

Comments
 (0)