mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* refactor(web): use src.logging_config in the web process; routine requests to DEBUG
The web interface had its own logging setup (web_interface/logging_config.py)
that replaced the root handlers with a plain stdout formatter. The web
service's journal lines therefore never carried a syslog priority, so
`journalctl -p err -u ledmatrix-web` returned nothing while errors were
logged, and the line shape differed from the display's (the log viewer's
prefix stripping only matched the display format). It also ran after the
module-level managers were built, so their INFO lines at import (including
"Re-removed N uninstalled plugin(s)") were dropped.
app.py now calls src.logging_config.setup_logging() first thing, the same as
run.py: journald priorities under systemd, LEDMATRIX_DEBUG honoured,
LEDMATRIX_JSON_LOGGING still selects JSON.
Per-request logging moves to web_interface/request_logging.py. Every request
used to be logged at INFO, so the UI's polling filled the journal
("GET /api/v3/errors/summary - 200" every minute per tab). Now a successful
GET/HEAD/OPTIONS is DEBUG, a successful write is INFO, 4xx WARNING, 5xx
ERROR. Durations use perf_counter and print to 0.1ms.
The duplicate module is deleted; nothing else imported it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(web): one thread-safe TTL cache for the web process
web_interface/cache.py becomes a small TTLCache class (lock-guarded,
monotonic clock) with the existing get_cached/set_cached/delete_cached/
invalidate_cache helpers kept on top of a shared instance, so the api_v3
callers are unchanged.
Bugs fixed:
- set_cached(ttl_seconds=...) ignored its TTL; only the reader's value
counted and get_cached defaulted to 60s. An entry now expires after the TTL
it was stored with; a reader's ttl_seconds can only shorten that. Both
current callers pass the same value on both sides (fonts_catalog 300s,
system_status 10s), so their observable TTLs are unchanged.
- get_cached deleted expired keys without a lock; two threads reading the
same expired key could raise KeyError (reproduced), which the endpoints
turned into a 500.
app.py's two hand-rolled systemctl caches (_ap_mode_cache, 30s, and
_ledmatrix_service_cache, 15s) now share one helper over a private
TTLCache, with the same TTLs. The AP-mode check used to retry on every
request after a failure (and log an ERROR each time); a failure now keeps the
last known answer for the TTL, as the display-service check already did. With
no systemctl at all (a dev machine) it answers False without forking.
Left alone as not TTL memoisation: the gzip cache (size-bounded, keyed by URL
and version), the settings search index (keyed by installed-plugin set), the
widget bundle (keyed by file fingerprint) and CacheManager (cross-process).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): web logging and TTL cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): only ask systemctl about known units
Codacy flagged the systemctl argv built from a variable. The unit now has
to be one of two literals, and anything else raises.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): response_time_ms reads the same clock request_logging stamps
request_logging now stamps request.start_time from perf_counter, but
success_response still subtracted it from time.time(), so metadata
reported ~1.8e12 ms. Found testing on ledpi.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
5.9 KiB
Python
191 lines
5.9 KiB
Python
"""Tests for the web interface's in-memory cache helpers."""
|
|
import sys
|
|
import threading
|
|
from typing import Iterator
|
|
|
|
import pytest
|
|
|
|
from web_interface import cache as cache_module
|
|
from web_interface.cache import (
|
|
TTLCache, delete_cached, get_cached, invalidate_cache, set_cached,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_cache() -> Iterator[None]:
|
|
"""Start and finish every test with an empty cache."""
|
|
invalidate_cache()
|
|
yield
|
|
invalidate_cache()
|
|
|
|
|
|
def test_set_and_get() -> None:
|
|
"""A cached value is returned before its TTL expires."""
|
|
set_cached('key', 'value')
|
|
assert get_cached('key') == 'value'
|
|
|
|
|
|
def test_get_missing_returns_none() -> None:
|
|
"""Reading an unknown key returns None."""
|
|
assert get_cached('missing') is None
|
|
|
|
|
|
def test_delete_cached_removes_key() -> None:
|
|
"""delete_cached removes exactly the named key."""
|
|
set_cached('fonts_catalog', ['a-font'])
|
|
delete_cached('fonts_catalog')
|
|
assert get_cached('fonts_catalog') is None
|
|
|
|
|
|
def test_delete_cached_missing_key_is_noop() -> None:
|
|
"""Deleting a key that was never set must not raise."""
|
|
delete_cached('never-set')
|
|
|
|
|
|
def test_invalidate_cache_pattern() -> None:
|
|
"""Pattern invalidation removes matching keys and keeps the rest."""
|
|
set_cached('fonts_catalog', 1)
|
|
set_cached('plugins_list', 2)
|
|
invalidate_cache('fonts')
|
|
assert get_cached('fonts_catalog') is None
|
|
assert get_cached('plugins_list') == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Expiry. set_cached used to accept ttl_seconds and ignore it; only the TTL a
|
|
# reader passed to get_cached counted, and get_cached defaulted to 60s.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _Clock:
|
|
def __init__(self) -> None:
|
|
self.now = 1000.0
|
|
|
|
def __call__(self) -> float:
|
|
return self.now
|
|
|
|
|
|
@pytest.fixture
|
|
def clock() -> _Clock:
|
|
return _Clock()
|
|
|
|
|
|
def test_entry_expires_after_its_ttl(clock: _Clock) -> None:
|
|
c = TTLCache(clock=clock)
|
|
c.set('k', 'v', ttl=10)
|
|
clock.now += 9.9
|
|
assert c.get('k') == 'v'
|
|
clock.now += 0.1
|
|
assert c.get('k') is None
|
|
|
|
|
|
def test_default_ttl_applies_when_none_given(clock: _Clock) -> None:
|
|
c = TTLCache(default_ttl=5, clock=clock)
|
|
c.set('k', 'v')
|
|
clock.now += 4.9
|
|
assert c.get('k') == 'v'
|
|
clock.now += 0.1
|
|
assert c.get('k') is None
|
|
|
|
|
|
def test_reader_max_age_can_only_shorten(clock: _Clock) -> None:
|
|
c = TTLCache(clock=clock)
|
|
c.set('k', 'v', ttl=10)
|
|
clock.now += 5
|
|
assert c.get('k', max_age=6) == 'v'
|
|
assert c.get('k', max_age=5) is None
|
|
clock.now += 5
|
|
assert c.get('k', max_age=60) is None, "a reader extended a 10s entry"
|
|
|
|
|
|
def test_set_cached_ttl_is_honoured(monkeypatch: pytest.MonkeyPatch, clock: _Clock) -> None:
|
|
monkeypatch.setattr(cache_module, '_default_cache', TTLCache(clock=clock))
|
|
set_cached('short', 1, ttl_seconds=2)
|
|
set_cached('long', 2, ttl_seconds=300)
|
|
clock.now += 2
|
|
assert get_cached('short') is None, "set_cached ignored its ttl_seconds"
|
|
clock.now += 100 # past the old implicit 60s read default
|
|
assert get_cached('long') == 2
|
|
|
|
|
|
def test_get_cached_ttl_still_bounds_the_read(monkeypatch: pytest.MonkeyPatch, clock: _Clock) -> None:
|
|
"""The existing callers pass the TTL on both sides; that keeps working."""
|
|
monkeypatch.setattr(cache_module, '_default_cache', TTLCache(clock=clock))
|
|
set_cached('system_status', {'cpu': 1}, ttl_seconds=10)
|
|
clock.now += 9
|
|
assert get_cached('system_status', ttl_seconds=10) == {'cpu': 1}
|
|
clock.now += 1
|
|
assert get_cached('system_status', ttl_seconds=10) is None
|
|
|
|
|
|
def test_peek_returns_the_last_value_after_expiry(clock: _Clock) -> None:
|
|
c = TTLCache(clock=clock)
|
|
assert c.peek('k', 'fallback') == 'fallback'
|
|
c.set('k', True, ttl=1)
|
|
clock.now += 5
|
|
assert c.get('k') is None
|
|
assert c.peek('k', False) is True
|
|
|
|
|
|
def test_falsy_values_are_cached(clock: _Clock) -> None:
|
|
c = TTLCache(clock=clock)
|
|
c.set('k', False, ttl=10)
|
|
assert c.get('k', default='miss') is False
|
|
|
|
|
|
def test_clear_pattern_on_instance() -> None:
|
|
c = TTLCache()
|
|
c.set('fonts_catalog', 1)
|
|
c.set('system_status', 2)
|
|
c.clear('fonts')
|
|
assert c.peek('fonts_catalog') is None
|
|
assert c.get('system_status') == 2
|
|
c.clear()
|
|
assert c.peek('system_status') is None
|
|
|
|
|
|
def test_concurrent_expiry_reads_and_writes_do_not_raise() -> None:
|
|
"""The old dicts deleted expired keys inside get; two threads reading the
|
|
same expired key (or one reading while another invalidated) could raise
|
|
KeyError, which the endpoints turned into a 500."""
|
|
c = TTLCache()
|
|
keys = [f'k{n}' for n in range(8)]
|
|
errors = []
|
|
stop = threading.Event()
|
|
|
|
def reader() -> None:
|
|
try:
|
|
while not stop.is_set():
|
|
for key in keys:
|
|
c.get(key, max_age=0) # always expired for this reader
|
|
c.get(key)
|
|
c.peek(key)
|
|
c.clear('k1')
|
|
except Exception as exc: # pragma: no cover - the failure being tested
|
|
errors.append(exc)
|
|
|
|
def writer() -> None:
|
|
try:
|
|
for i in range(20000):
|
|
key = keys[i % len(keys)]
|
|
c.set(key, i, ttl=0 if i % 2 else 60)
|
|
if i % 7 == 0:
|
|
c.delete(key)
|
|
except Exception as exc: # pragma: no cover
|
|
errors.append(exc)
|
|
|
|
# Switch threads as often as possible so an unlocked check-then-act
|
|
# actually gets interleaved within the test's run time.
|
|
old_interval = sys.getswitchinterval()
|
|
sys.setswitchinterval(1e-6)
|
|
try:
|
|
readers = [threading.Thread(target=reader) for _ in range(4)]
|
|
for t in readers:
|
|
t.start()
|
|
writer()
|
|
stop.set()
|
|
for t in readers:
|
|
t.join()
|
|
finally:
|
|
sys.setswitchinterval(old_interval)
|
|
assert errors == []
|