Files
chicken_babies_site/app/services/cache.py
Phillip Tarrant 0306f71763 feat: phase 2 content model + cache — SQLite schema, markdown, TTL
Stand up the full SQLite content layer: all 7 tables from the authoritative
schema with WAL + foreign-keys enforced per-connection, entity dataclasses
plus row mappers, hand-rolled versioned migrations tracked in
schema_migrations, and an idempotent Python seed (system user + welcome
post + About page).

Add a Markdown->HTML service using markdown-it-py with a strict bleach
allowlist (tables intentionally omitted on both sides). Add a typed
in-process TTLCache[K,V] and wire it into real DB-backed PostService and
PageService, both exposing invalidate_all() for Phase 4 admin writes.

Rewire / and /about to read from the DB; homepage renders the seeded
welcome post, About renders page.title + sanitized body_html_cached.
Update the Phase 1 route tests accordingly.

Mark Phase 2 complete in docs/ROADMAP.md.
2026-04-21 15:40:35 -05:00

89 lines
3.2 KiB
Python

"""In-process, generic TTL cache.
Small, typed, and deliberately boring. Used by :mod:`app.services.posts`
and :mod:`app.services.pages` to sit in front of the hottest queries
(published-posts list, page-by-slug); a 60 s default TTL keeps the
site's three-digit daily requests out of the SQLite query path without
any cross-process coordination.
Not thread-safe in the strict sense — Python's GIL makes the dict
operations atomic at CPython bytecode granularity, and worst case a
concurrent writer causes a benign duplicate DB read. That is
acceptable at this scale; if the site ever grows teeth we can revisit.
"""
from __future__ import annotations
import time
from typing import Generic, Hashable, Optional, TypeVar
# TypeVar bound to ``Hashable`` so callers cannot accidentally key by a
# mutable collection (which would later look up with a different hash
# after mutation and silently miss the cache).
K = TypeVar("K", bound=Hashable)
V = TypeVar("V")
class TTLCache(Generic[K, V]):
"""Tiny TTL-based dict-style cache.
Entries expire ``ttl_seconds`` after insertion. Expired entries
are dropped lazily on access — there is no background sweep, and
the cache is not bounded in size. For our workload (at most a
few dozen keys per instance) this is fine.
Two operations are public:
- :meth:`get` returns the cached value or ``None``.
- :meth:`set` stores a value with an expiry.
- :meth:`invalidate_all` clears every entry; used by admin-write
paths in Phase 4.
"""
def __init__(self, ttl_seconds: float = 60.0) -> None:
"""Construct an empty cache.
Parameters
----------
ttl_seconds:
Time-to-live for every entry, in seconds. 60 s matches the
"Caching Strategy" section of ``docs/ROADMAP.md``.
"""
if ttl_seconds <= 0:
# Defensive: a zero/negative TTL would mean every write
# instantly expires, which almost always indicates a bug.
raise ValueError("ttl_seconds must be positive")
self._ttl: float = float(ttl_seconds)
# Stored as (expiry_monotonic_ts, value). Using
# ``time.monotonic`` avoids issues if the wall clock jumps.
self._store: dict[K, tuple[float, V]] = {}
def get(self, key: K) -> Optional[V]:
"""Return the cached value for ``key`` or ``None`` if absent/expired.
Expired entries are deleted as a side effect of the lookup so
the store doesn't grow unboundedly with stale data in
long-running processes.
"""
entry = self._store.get(key)
if entry is None:
return None
expiry, value = entry
if time.monotonic() >= expiry:
# Expired — drop lazily and report miss.
self._store.pop(key, None)
return None
return value
def set(self, key: K, value: V) -> None:
"""Store ``value`` under ``key`` with the configured TTL."""
self._store[key] = (time.monotonic() + self._ttl, value)
def invalidate_all(self) -> None:
"""Drop every cached entry.
Called by the Phase 4 admin write path so readers see the new
content on the very next request, not up to 60 s later.
"""
self._store.clear()