فهرست منبع

extract MonthCache/TTLCache from service clients

Both clients hand-rolled the same month-granularity caching: TTL eviction,
in-flight task dedup, derived per-date cache cleanup, and the eventual-
consistency suspect retry. MonthCache (service_api/_month_cache.py) now owns
those invariants generically; clients supply a fetch callable, a suspect
predicate, and hooks for derived-cache eviction. Airbnb's _had_bookings
stale-replica guard plugs in via the revalidate/on_fetch hooks; its
reservation-detail cache moves to the string-keyed TTLCache variant.

Booking's suspect predicate now excludes fully-past months (which
legitimately return no data) since the retry moved above the _is_past
early return in the fetch path.
ZiB 3 ماه پیش
والد
کامیت
252247a102

+ 49 - 80
room_cal/service_api/_airbnb.py

@@ -4,7 +4,6 @@ import json
 import os
 import random
 import string
-import time
 import uuid
 from datetime import date, datetime, timedelta
 from zoneinfo import ZoneInfo
@@ -16,7 +15,8 @@ import logging
 
 from ._scripts import _assert_not_async, _npm_env, _ts_dir, _ensure_installed
 from room_cal._utils import data_home, get_config, read_env
-from ._utils import CACHE_TTL, month_bounds, months_in_range
+from ._month_cache import MonthCache, TTLCache
+from ._utils import month_bounds, months_in_range
 from .credentials import is_stale, mark_stale
 from .types import CalendarEvent, DayInfo, ServiceAuthError, Reservation
 
@@ -131,17 +131,22 @@ class _AirbnbClient:
     self._tz = tz
     self._cache: dict[str, DayInfo] = {}
     self._fetched_months: set[tuple[int, int]] = set()
-    self._raw_months: dict[tuple[int, int], list[dict]] = {}
-    self._month_fetched_at: dict[tuple[int, int], float] = {}
-    self._months_inflight: dict[tuple[int, int], asyncio.Task] = {}
     # Tracks months where we've seen at least one booked day; survives clear_cache() so we can
     # detect when a freshly-fetched month drops from N booked days to 0 (stale replica signal).
     self._had_bookings: set[tuple[int, int]] = set()
+    self._months: MonthCache[list[dict]] = MonthCache(
+      lambda m, y: self._fetch_month_days(m, y),
+      suspect=self._suspect_days,
+      revalidate=self._revalidate_month,
+      on_fetch=self._note_bookings,
+      on_evict=self._evict_derived,
+      label='Airbnb',
+      log=_log,
+    )
+    self._details: TTLCache[dict] = TTLCache()
     self._refresh_lock = asyncio.Lock()
     self._refresh_task: asyncio.Task | None = None
     self._http = httpx.AsyncClient(timeout=10.0)
-    self._detail_cache: dict[str, dict] = {}
-    self._detail_fetched_at: dict[str, float] = {}
     self._booked_nets: dict[str, int] = {}
 
   @classmethod
@@ -159,10 +164,8 @@ class _AirbnbClient:
   def clear_cache(self) -> None:
     self._cache.clear()
     self._fetched_months.clear()
-    self._raw_months.clear()
-    self._month_fetched_at.clear()
-    self._detail_cache.clear()
-    self._detail_fetched_at.clear()
+    self._months.clear()
+    self._details.clear()
     self._booked_nets.clear()
 
   def _reload_from_env(self) -> None:
@@ -304,79 +307,48 @@ class _AirbnbClient:
       'readConsistency': 'EVENTUAL',
     }
     referer = f'https://www.airbnb.com/multicalendar/{self._listing_id}'
+    resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
+    section = (
+      resp.get('data', {}).get('presentation', {})
+          .get('hostCalendar', {}).get('sections', {})
+          .get('calendarGridViewSection')
+    ) or {}
+    return section.get('days', [])
+
+  def _suspect_days(self, key: tuple[int, int], days: list[dict]) -> bool:
+    return not days or not any(d.get('priceData', {}).get('price') is not None for d in days)
+
+  def _revalidate_month(self, key: tuple[int, int], cached: list[dict]) -> bool:
+    # If we've seen bookings for this month before but the cache now shows none,
+    # the data is likely from a stale eventual-consistency replica — force a refetch.
+    if key in self._had_bookings and not any(d.get('reservationData') for d in cached):
+      month, year = key
+      _log.warning(
+        'Airbnb: %d-%02d cache shows 0 booked days but month had bookings; forcing refetch',
+        year, month,
+      )
+      return False
+    return True
 
-    def _extract_days(resp: dict) -> list[dict]:
-      section = (
-        resp.get('data', {}).get('presentation', {})
-            .get('hostCalendar', {}).get('sections', {})
-            .get('calendarGridViewSection')
-      ) or {}
-      return section.get('days', [])
-
-    def _has_prices(days: list[dict]) -> bool:
-      return any(d.get('priceData', {}).get('price') is not None for d in days)
+  def _note_bookings(self, key: tuple[int, int], result: list[dict], revalidation_failed: bool) -> None:
+    if any(d.get('reservationData') for d in result):
+      self._had_bookings.add(key)
+    elif revalidation_failed:
+      # Forced refetch confirmed no bookings — stop guarding this month
+      self._had_bookings.discard(key)
 
-    resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
-    days = _extract_days(resp)
-    if not days or not _has_prices(days):
-      _log.warning('Airbnb: empty or priceless calendar for %d-%02d; retrying', year, month)
-      await asyncio.sleep(1)
-      resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
-      days = _extract_days(resp)
-      if not days or not _has_prices(days):
-        _log.warning('Airbnb: still empty/priceless calendar for %d-%02d after retry', year, month)
-    return days
-
-  def _evict_month(self, key: tuple[int, int]) -> None:
+  def _evict_derived(self, key: tuple[int, int]) -> None:
     month, year = key
-    self._raw_months.pop(key, None)
-    self._month_fetched_at.pop(key, None)
     self._fetched_months.discard(key)
     prefix = f'{year}-{month:02d}'
     for d in [k for k in self._cache if k.startswith(prefix)]:
       del self._cache[d]
 
-  async def _get_month_days(self, month: int, year: int) -> list[dict]:
-    key = (month, year)
-    forced_refetch = False
-    if key in self._raw_months:
-      cached = self._raw_months[key]
-      fetched_at = self._month_fetched_at.get(key)
-      if fetched_at is None or time.monotonic() - fetched_at > CACHE_TTL:
-        self._evict_month(key)
-      # If we've seen bookings for this month before but the cache now shows none,
-      # the data is likely from a stale eventual-consistency replica — force a refetch.
-      elif key in self._had_bookings and not any(d.get('reservationData') for d in cached):
-        _log.warning(
-          'Airbnb: %d-%02d cache shows 0 booked days but month had bookings; forcing refetch',
-          year, month,
-        )
-        self._evict_month(key)
-        forced_refetch = True
-      else:
-        return cached
-    if key in self._months_inflight:
-      return await self._months_inflight[key]
-    task = asyncio.create_task(self._fetch_month_days(month, year))
-    self._months_inflight[key] = task
-    try:
-      result = await task
-    finally:
-      self._months_inflight.pop(key, None)
-    if any(d.get('reservationData') for d in result):
-      self._had_bookings.add(key)
-    elif forced_refetch:
-      # Forced refetch confirmed no bookings — stop guarding this month
-      self._had_bookings.discard(key)
-    self._raw_months[key] = result
-    self._month_fetched_at[key] = time.monotonic()
-    return result
-
   async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
     if is_stale('airbnb'):
       raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
     month_list = months_in_range(date_from, date_to)
-    all_days = await asyncio.gather(*[self._get_month_days(m, y) for m, y in month_list])
+    all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
     for (month, year), days in zip(month_list, all_days):
       if (month, year) not in self._fetched_months:
         for day in days:
@@ -407,7 +379,7 @@ class _AirbnbClient:
     if is_stale('airbnb'):
       raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
     month_list = months_in_range(date_from, date_to)
-    all_days = await asyncio.gather(*[self._get_month_days(m, y) for m, y in month_list])
+    all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
     booked: dict[str, str] = {}
     for days in all_days:
       for day in days:
@@ -496,7 +468,7 @@ class _AirbnbClient:
     if is_stale('airbnb'):
       raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
     month_list = months_in_range(date_from, date_to)
-    all_days = await asyncio.gather(*[self._get_month_days(m, y) for m, y in month_list])
+    all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
 
     booked_codes: dict[str, str] = {}  # date → confirmation code
     for days in all_days:
@@ -574,10 +546,8 @@ class _AirbnbClient:
     return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
 
   async def _fetch_one_detail(self, code: str) -> dict:
-    if code in self._detail_cache:
-      fetched_at = self._detail_fetched_at.get(code)
-      if fetched_at is not None and time.monotonic() - fetched_at <= CACHE_TTL:
-        return self._detail_cache[code]
+    if (cached := self._details.get(code)) is not None:
+      return cached
     try:
       resp = await self._gql(
         'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
@@ -591,8 +561,7 @@ class _AirbnbClient:
       resp.get('data', {}).get('presentation', {})
           .get('hostingDetails', {}).get('stayHostingDetails', {})
     )
-    self._detail_cache[code] = det
-    self._detail_fetched_at[code] = time.monotonic()
+    self._details.set(code, det)
     return det
 
   def _global_thread_id(self, thread_id: str) -> str:

+ 23 - 42
room_cal/service_api/_booking.py

@@ -12,7 +12,8 @@ import httpx
 
 from ._scripts import _assert_not_async, _booking_lock, _npm_env, _ts_dir, _ensure_installed
 from room_cal._utils import data_home, get_config, read_env
-from ._utils import CACHE_TTL, month_bounds, months_in_range
+from ._month_cache import MonthCache
+from ._utils import month_bounds, months_in_range
 from .credentials import clear_stale, is_stale, mark_stale, set_credentials
 from .types import DayInfo, ServiceAuthError, Reservation
 
@@ -167,10 +168,13 @@ class _BookingClient:
     self._csrf_token = csrf_token
     self._lang = lang
     self._cache: dict[str, DayInfo] = {}
-    self._fetched_months: set[tuple[int, int]] = set()
-    self._month_data: dict[tuple[int, int], dict[str, DayInfo]] = {}
-    self._month_fetched_at: dict[tuple[int, int], float] = {}
-    self._months_inflight: dict[tuple[int, int], asyncio.Task] = {}
+    self._months: MonthCache[dict[str, DayInfo]] = MonthCache(
+      lambda m, y: self._fetch_month_calendar(m, y),
+      suspect=self._suspect_month,
+      on_evict=self._evict_derived,
+      label='Booking.com',
+      log=_log,
+    )
     self._refresh_lock = asyncio.Lock()
     self._refresh_task: asyncio.Task | None = None
     self._http = httpx.AsyncClient(timeout=10.0)
@@ -199,9 +203,7 @@ class _BookingClient:
 
   def clear_cache(self) -> None:
     self._cache.clear()
-    self._fetched_months.clear()
-    self._month_data.clear()
-    self._month_fetched_at.clear()
+    self._months.clear()
 
   def _reload_from_env(self) -> None:
     env_file = data_home() / 'booking.env'
@@ -527,46 +529,25 @@ class _BookingClient:
     raw = await self._multipart_post(
       f'{_BASE}/fresa/extranet/inventory/fetch', self._monthly_referer(), body
     )
-    result = self._parse_calendar_days(raw)
-    if not result or not any(info.price is not None for info in result.values()):
-      _log.warning('Booking.com: empty or priceless calendar for %d-%02d; retrying', year, month)
-      await asyncio.sleep(1)
-      raw = await self._multipart_post(
-        f'{_BASE}/fresa/extranet/inventory/fetch', self._monthly_referer(), body
-      )
-      result = self._parse_calendar_days(raw)
-      if not result or not any(info.price is not None for info in result.values()):
-        _log.warning('Booking.com: still empty/priceless calendar for %d-%02d after retry', year, month)
-    return result
+    return self._parse_calendar_days(raw)
 
-  async def _get_month_calendar(self, month: int, year: int) -> dict[str, DayInfo]:
-    key = (month, year)
-    if key in self._month_data:
-      fetched_at = self._month_fetched_at.get(key)
-      if fetched_at is not None and time.monotonic() - fetched_at <= CACHE_TTL:
-        return self._month_data[key]
-      del self._month_data[key]
-      self._month_fetched_at.pop(key, None)
-      prefix = f'{year}-{month:02d}'
-      for d in [k for k in self._cache if k.startswith(prefix)]:
-        del self._cache[d]
-    if key in self._months_inflight:
-      return await self._months_inflight[key]
-    task = asyncio.create_task(self._fetch_month_calendar(month, year))
-    self._months_inflight[key] = task
-    try:
-      result = await task
-    finally:
-      self._months_inflight.pop(key, None)
-    self._month_data[key] = result
-    self._month_fetched_at[key] = time.monotonic()
-    return result
+  def _suspect_month(self, key: tuple[int, int], result: dict[str, DayInfo]) -> bool:
+    month, year = key
+    if _is_past(month_bounds(month, year)[1]):
+      return False  # past months legitimately return no data
+    return not result or not any(info.price is not None for info in result.values())
+
+  def _evict_derived(self, key: tuple[int, int]) -> None:
+    month, year = key
+    prefix = f'{year}-{month:02d}'
+    for d in [k for k in self._cache if k.startswith(prefix)]:
+      del self._cache[d]
 
   async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
     if is_stale('booking'):
       raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
     month_list = months_in_range(date_from, date_to)
-    all_results = await asyncio.gather(*[self._get_month_calendar(m, y) for m, y in month_list])
+    all_results = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
     for month_result in all_results:
       self._cache.update(month_result)
     return {d: info for d, info in self._cache.items() if date_from <= d <= date_to}

+ 140 - 0
room_cal/service_api/_month_cache.py

@@ -0,0 +1,140 @@
+"""Month-granularity TTL caches shared by service clients.
+
+MonthCache owns the caching invariants every calendar-backed client needs:
+TTL eviction (a missing timestamp counts as expired), in-flight request
+deduplication, the eventual-consistency suspect-retry, and hooks for
+client-specific revalidation and derived-cache cleanup. TTLCache is the
+string-keyed variant for per-item detail caches.
+
+Clients pass a late-binding fetch callable (e.g. ``lambda m, y:
+self._fetch_month(m, y)``) so tests can stub the fetch method on the
+client instance.
+"""
+import asyncio
+import logging
+import time
+from collections.abc import Awaitable, Callable
+
+from ._utils import CACHE_TTL
+
+_log = logging.getLogger(__name__)
+
+Key = tuple[int, int]  # (month, year)
+
+
+class MonthCache[T]:
+  """Async cache of per-month payloads keyed by (month, year).
+
+  Hooks (all optional):
+  - suspect(key, result): truthy result looks like stale-replica data
+    (empty/priceless) — retry the fetch once after retry_delay, then accept.
+  - revalidate(key, cached): return False to evict a fresh-by-TTL entry and
+    refetch (e.g. Airbnb's had-bookings stale-replica guard).
+  - on_fetch(key, result, revalidation_failed): post-fetch bookkeeping; the
+    flag is True when the fetch was forced by a failed revalidation.
+  - on_evict(key): clean up derived client state (per-date caches etc.);
+    called on TTL eviction, failed revalidation, and invalidate() — not by
+    clear(), which wipes wholesale while the client clears its own state.
+  """
+
+  def __init__(
+    self,
+    fetch: Callable[[int, int], Awaitable[T]],
+    *,
+    suspect: Callable[[Key, T], bool] | None = None,
+    revalidate: Callable[[Key, T], bool] | None = None,
+    on_fetch: Callable[[Key, T, bool], None] | None = None,
+    on_evict: Callable[[Key], None] | None = None,
+    label: str = '',
+    log: logging.Logger | None = None,
+    ttl: float = CACHE_TTL,
+    retry_delay: float = 1.0,
+  ):
+    self._fetch = fetch
+    self._suspect = suspect
+    self._revalidate = revalidate
+    self._on_fetch = on_fetch
+    self._on_evict = on_evict
+    self._label = label
+    self._log = log or _log
+    self._ttl = ttl
+    self._retry_delay = retry_delay
+    self._data: dict[Key, T] = {}
+    self._fetched_at: dict[Key, float] = {}
+    self._inflight: dict[Key, asyncio.Task] = {}
+
+  async def get(self, month: int, year: int) -> T:
+    key = (month, year)
+    revalidation_failed = False
+    if key in self._data:
+      cached = self._data[key]
+      fetched_at = self._fetched_at.get(key)
+      if fetched_at is None or time.monotonic() - fetched_at > self._ttl:
+        self.invalidate(month, year)
+      elif self._revalidate is not None and not self._revalidate(key, cached):
+        self.invalidate(month, year)
+        revalidation_failed = True
+      else:
+        return cached
+    if key in self._inflight:
+      return await self._inflight[key]
+    task = asyncio.create_task(self._fetch_with_retry(month, year))
+    self._inflight[key] = task
+    try:
+      result = await task
+    finally:
+      self._inflight.pop(key, None)
+    if self._on_fetch is not None:
+      self._on_fetch(key, result, revalidation_failed)
+    self._data[key] = result
+    self._fetched_at[key] = time.monotonic()
+    return result
+
+  def invalidate(self, month: int, year: int) -> None:
+    key = (month, year)
+    self._data.pop(key, None)
+    self._fetched_at.pop(key, None)
+    if self._on_evict is not None:
+      self._on_evict(key)
+
+  def clear(self) -> None:
+    self._data.clear()
+    self._fetched_at.clear()
+
+  async def _fetch_with_retry(self, month: int, year: int) -> T:
+    key = (month, year)
+    result = await self._fetch(month, year)
+    if self._suspect is not None and self._suspect(key, result):
+      self._log.warning('%s: empty or priceless calendar for %d-%02d; retrying', self._label, year, month)
+      await asyncio.sleep(self._retry_delay)
+      result = await self._fetch(month, year)
+      if self._suspect(key, result):
+        self._log.warning('%s: still empty/priceless calendar for %d-%02d after retry', self._label, year, month)
+    return result
+
+
+class TTLCache[T]:
+  """String-keyed cache with per-entry TTL; a missing timestamp counts as expired."""
+
+  def __init__(self, ttl: float = CACHE_TTL):
+    self._ttl = ttl
+    self._data: dict[str, T] = {}
+    self._fetched_at: dict[str, float] = {}
+
+  def get(self, key: str) -> T | None:
+    if key not in self._data:
+      return None
+    fetched_at = self._fetched_at.get(key)
+    if fetched_at is None or time.monotonic() - fetched_at > self._ttl:
+      self._data.pop(key, None)
+      self._fetched_at.pop(key, None)
+      return None
+    return self._data[key]
+
+  def set(self, key: str, value: T) -> None:
+    self._data[key] = value
+    self._fetched_at[key] = time.monotonic()
+
+  def clear(self) -> None:
+    self._data.clear()
+    self._fetched_at.clear()

+ 152 - 0
tests/test_month_cache.py

@@ -0,0 +1,152 @@
+"""Unit tests for service_api._month_cache (MonthCache / TTLCache).
+
+Client-side wiring of these caches is covered in test_service_cache_ttl.py
+and test_reservation_guards.py; this file tests the cache mechanics directly.
+"""
+import asyncio
+import time
+
+from room_cal.service_api._month_cache import MonthCache, TTLCache
+from room_cal.service_api._utils import CACHE_TTL
+
+_KEY = (7, 2026)
+_EXPIRED = -(CACHE_TTL + 1)
+
+
+def _counting_fetch(results=None):
+  calls = []
+  async def _fetch(m, y):
+    calls.append((m, y))
+    if results:
+      return results.pop(0)
+    return ['data']
+  return _fetch, calls
+
+
+class TestMonthCache:
+
+  async def test_fetch_and_cache(self):
+    fetch, calls = _counting_fetch()
+    cache = MonthCache(fetch)
+    assert await cache.get(7, 2026) == ['data']
+    assert await cache.get(7, 2026) == ['data']
+    assert calls == [_KEY]
+
+  async def test_ttl_expiry_refetches(self):
+    fetch, calls = _counting_fetch()
+    cache = MonthCache(fetch)
+    await cache.get(7, 2026)
+    cache._fetched_at[_KEY] += _EXPIRED
+    await cache.get(7, 2026)
+    assert calls == [_KEY, _KEY]
+
+  async def test_missing_timestamp_treated_as_expired(self):
+    fetch, calls = _counting_fetch()
+    cache = MonthCache(fetch)
+    cache._data[_KEY] = ['seeded']
+    assert await cache.get(7, 2026) == ['data']
+    assert calls == [_KEY]
+
+  async def test_inflight_dedup(self):
+    calls = []
+    async def _fetch(m, y):
+      calls.append((m, y))
+      await asyncio.sleep(0.01)
+      return ['data']
+    cache = MonthCache(_fetch)
+    results = await asyncio.gather(cache.get(7, 2026), cache.get(7, 2026), cache.get(7, 2026))
+    assert calls == [_KEY]
+    assert all(r == ['data'] for r in results)
+
+  async def test_suspect_retries_once(self, caplog):
+    fetch, calls = _counting_fetch(results=[[], ['data']])
+    cache = MonthCache(fetch, suspect=lambda key, r: not r, label='Test', retry_delay=0)
+    assert await cache.get(7, 2026) == ['data']
+    assert calls == [_KEY, _KEY]
+    assert 'empty or priceless calendar for 2026-07; retrying' in caplog.text
+
+  async def test_suspect_persists_returns_bad_data(self, caplog):
+    fetch, calls = _counting_fetch(results=[[], []])
+    cache = MonthCache(fetch, suspect=lambda key, r: not r, label='Test', retry_delay=0)
+    assert await cache.get(7, 2026) == []
+    assert calls == [_KEY, _KEY]
+    assert 'still empty/priceless calendar for 2026-07 after retry' in caplog.text
+
+  async def test_good_result_skips_retry(self):
+    fetch, calls = _counting_fetch()
+    cache = MonthCache(fetch, suspect=lambda key, r: not r, retry_delay=0)
+    await cache.get(7, 2026)
+    assert calls == [_KEY]
+
+  async def test_revalidate_false_evicts_and_refetches(self):
+    fetch, calls = _counting_fetch()
+    seen = []
+    cache = MonthCache(
+      fetch,
+      revalidate=lambda key, cached: False,
+      on_fetch=lambda key, result, revalidation_failed: seen.append(revalidation_failed),
+    )
+    cache._data[_KEY] = ['stale']
+    cache._fetched_at[_KEY] = time.monotonic()
+    assert await cache.get(7, 2026) == ['data']
+    assert calls == [_KEY]
+    assert seen == [True]
+
+  async def test_revalidate_true_serves_cache(self):
+    fetch, calls = _counting_fetch()
+    cache = MonthCache(fetch, revalidate=lambda key, cached: True)
+    cache._data[_KEY] = ['cached']
+    cache._fetched_at[_KEY] = time.monotonic()
+    assert await cache.get(7, 2026) == ['cached']
+    assert calls == []
+
+  async def test_on_fetch_flag_false_on_plain_fetch(self):
+    fetch, _ = _counting_fetch()
+    seen = []
+    cache = MonthCache(fetch, on_fetch=lambda key, result, revalidation_failed: seen.append(revalidation_failed))
+    await cache.get(7, 2026)
+    assert seen == [False]
+
+  async def test_on_evict_called_on_expiry_and_invalidate_not_clear(self):
+    fetch, _ = _counting_fetch()
+    evicted = []
+    cache = MonthCache(fetch, on_evict=evicted.append)
+    await cache.get(7, 2026)
+    cache._fetched_at[_KEY] += _EXPIRED
+    await cache.get(7, 2026)
+    assert evicted == [_KEY]
+    cache.invalidate(7, 2026)
+    assert evicted == [_KEY, _KEY]
+    await cache.get(7, 2026)
+    cache.clear()
+    assert evicted == [_KEY, _KEY]
+    assert cache._data == {}
+
+
+class TestTTLCache:
+
+  def test_set_get(self):
+    cache = TTLCache()
+    cache.set('k', {'v': 1})
+    assert cache.get('k') == {'v': 1}
+
+  def test_missing_key(self):
+    assert TTLCache().get('nope') is None
+
+  def test_expired_entry_dropped(self):
+    cache = TTLCache()
+    cache.set('k', {'v': 1})
+    cache._fetched_at['k'] += _EXPIRED
+    assert cache.get('k') is None
+    assert 'k' not in cache._data
+
+  def test_missing_timestamp_treated_as_expired(self):
+    cache = TTLCache()
+    cache._data['k'] = {'v': 1}
+    assert cache.get('k') is None
+
+  def test_clear(self):
+    cache = TTLCache()
+    cache.set('k', {'v': 1})
+    cache.clear()
+    assert cache.get('k') is None

+ 15 - 14
tests/test_reservation_guards.py

@@ -112,12 +112,17 @@ class TestHadBookings:
   _fetch_month_days is replaced on the instance so no network calls are made.
   """
 
+  @staticmethod
+  def _seed(client, key, days):
+    client._months._data[key] = days
+    client._months._fetched_at[key] = time.monotonic()
+
   async def test_first_fetch_with_bookings_marks_month(self):
     client = _airbnb_client()
     async def _fetch(m, y): return _days(booked=True)
     client._fetch_month_days = _fetch
 
-    await client._get_month_days(7, 2026)
+    await client._months.get(7, 2026)
     assert (7, 2026) in client._had_bookings
 
   async def test_first_fetch_without_bookings_does_not_mark(self):
@@ -125,7 +130,7 @@ class TestHadBookings:
     async def _fetch(m, y): return _days(booked=False)
     client._fetch_month_days = _fetch
 
-    await client._get_month_days(7, 2026)
+    await client._months.get(7, 2026)
     assert (7, 2026) not in client._had_bookings
 
   async def test_stale_cache_forces_refetch(self):
@@ -133,8 +138,7 @@ class TestHadBookings:
     client = _airbnb_client()
     key = (7, 2026)
     client._had_bookings.add(key)
-    client._raw_months[key] = _days(booked=False)  # stale: no bookings
-    client._month_fetched_at[key] = time.monotonic()
+    self._seed(client, key, _days(booked=False))  # stale: no bookings
 
     calls = []
     async def _fetch(m, y):
@@ -142,7 +146,7 @@ class TestHadBookings:
       return _days(booked=True)
     client._fetch_month_days = _fetch
 
-    result = await client._get_month_days(7, 2026)
+    result = await client._months.get(7, 2026)
     assert calls == [(7, 2026)], "expected exactly one refetch"
     assert any(d.get('reservationData') for d in result)
 
@@ -150,8 +154,7 @@ class TestHadBookings:
     """Cache has 0 booked and _had_bookings has no entry → served from cache."""
     client = _airbnb_client()
     key = (7, 2026)
-    client._raw_months[key] = _days(booked=False)
-    client._month_fetched_at[key] = time.monotonic()
+    self._seed(client, key, _days(booked=False))
 
     calls = []
     async def _fetch(m, y):
@@ -159,7 +162,7 @@ class TestHadBookings:
       return _days(booked=True)
     client._fetch_month_days = _fetch
 
-    await client._get_month_days(7, 2026)
+    await client._months.get(7, 2026)
     assert calls == [], "cache hit should not trigger refetch"
 
   async def test_forced_refetch_confirms_empty_removes_mark(self):
@@ -167,13 +170,12 @@ class TestHadBookings:
     client = _airbnb_client()
     key = (7, 2026)
     client._had_bookings.add(key)
-    client._raw_months[key] = _days(booked=False)
-    client._month_fetched_at[key] = time.monotonic()
+    self._seed(client, key, _days(booked=False))
 
     async def _fetch(m, y): return _days(booked=False)  # still 0 on refetch
     client._fetch_month_days = _fetch
 
-    await client._get_month_days(7, 2026)
+    await client._months.get(7, 2026)
     assert key not in client._had_bookings, "confirmed empty — guard should be lifted"
 
   async def test_refetch_invalidates_fetched_months_and_cache(self):
@@ -184,8 +186,7 @@ class TestHadBookings:
     client = _airbnb_client()
     key = (7, 2026)
     client._had_bookings.add(key)
-    client._raw_months[key] = _days(booked=False)
-    client._month_fetched_at[key] = time.monotonic()
+    self._seed(client, key, _days(booked=False))
     client._fetched_months.add(key)
     client._cache['2026-07-10'] = _dummy
     client._cache['2026-08-01'] = _dummy  # different month — must survive
@@ -193,7 +194,7 @@ class TestHadBookings:
     async def _fetch(m, y): return _days(booked=True)
     client._fetch_month_days = _fetch
 
-    await client._get_month_days(7, 2026)
+    await client._months.get(7, 2026)
     assert key not in client._fetched_months
     assert '2026-07-10' not in client._cache
     assert '2026-08-01' in client._cache, "different month must not be evicted"

+ 32 - 34
tests/test_service_cache_ttl.py

@@ -3,6 +3,10 @@
 The month/detail caches exist to dedupe requests within one operation; entries
 older than the TTL must be refetched so upstream changes (e.g. a canceled
 reservation) can't outlive them in a long-running server process.
+
+Cache mechanics (TTL, inflight dedup, suspect retry) are covered directly in
+test_month_cache.py; these tests verify each client's wiring — that the month
+caches are hooked up with eviction of the derived per-date DayInfo cache.
 """
 import time
 
@@ -35,13 +39,16 @@ def _day_info(d='2026-07-10'):
 _EXPIRED = -(CACHE_TTL + 1)
 
 
+def _seed(cache, key, value, age=0.0):
+  cache._data[key] = value
+  cache._fetched_at[key] = time.monotonic() + age
+
+
 class TestAirbnbMonthTTL:
 
   async def test_fresh_month_served_from_cache(self):
     client = _airbnb_client()
-    key = (7, 2026)
-    client._raw_months[key] = _days()
-    client._month_fetched_at[key] = time.monotonic()
+    _seed(client._months, (7, 2026), _days())
 
     calls = []
     async def _fetch(m, y):
@@ -49,14 +56,12 @@ class TestAirbnbMonthTTL:
       return _days()
     client._fetch_month_days = _fetch
 
-    await client._get_month_days(7, 2026)
+    await client._months.get(7, 2026)
     assert calls == []
 
   async def test_expired_month_refetches(self):
     client = _airbnb_client()
-    key = (7, 2026)
-    client._raw_months[key] = _days()
-    client._month_fetched_at[key] = time.monotonic() + _EXPIRED
+    _seed(client._months, (7, 2026), _days(), age=_EXPIRED)
 
     calls = []
     async def _fetch(m, y):
@@ -64,14 +69,13 @@ class TestAirbnbMonthTTL:
       return _days(booked=True)
     client._fetch_month_days = _fetch
 
-    result = await client._get_month_days(7, 2026)
+    result = await client._months.get(7, 2026)
     assert calls == [(7, 2026)]
     assert any(d.get('reservationData') for d in result)
 
   async def test_missing_timestamp_treated_as_expired(self):
     client = _airbnb_client()
-    key = (7, 2026)
-    client._raw_months[key] = _days()
+    client._months._data[(7, 2026)] = _days()
 
     calls = []
     async def _fetch(m, y):
@@ -79,14 +83,13 @@ class TestAirbnbMonthTTL:
       return _days()
     client._fetch_month_days = _fetch
 
-    await client._get_month_days(7, 2026)
+    await client._months.get(7, 2026)
     assert calls == [(7, 2026)]
 
   async def test_expired_month_evicts_dayinfo_cache(self):
     client = _airbnb_client()
     key = (7, 2026)
-    client._raw_months[key] = _days()
-    client._month_fetched_at[key] = time.monotonic() + _EXPIRED
+    _seed(client._months, key, _days(), age=_EXPIRED)
     client._fetched_months.add(key)
     client._cache['2026-07-10'] = _day_info('2026-07-10')
     client._cache['2026-08-01'] = _day_info('2026-08-01')  # different month — must survive
@@ -94,7 +97,7 @@ class TestAirbnbMonthTTL:
     async def _fetch(m, y): return _days()
     client._fetch_month_days = _fetch
 
-    await client._get_month_days(7, 2026)
+    await client._months.get(7, 2026)
     assert key not in client._fetched_months
     assert '2026-07-10' not in client._cache
     assert '2026-08-01' in client._cache
@@ -105,9 +108,9 @@ class TestAirbnbMonthTTL:
     async def _fetch(m, y): return _days()
     client._fetch_month_days = _fetch
 
-    await client._get_month_days(7, 2026)
-    assert key in client._month_fetched_at
-    assert time.monotonic() - client._month_fetched_at[key] < CACHE_TTL
+    await client._months.get(7, 2026)
+    assert key in client._months._fetched_at
+    assert time.monotonic() - client._months._fetched_at[key] < CACHE_TTL
 
 
 class TestAirbnbDetailTTL:
@@ -119,29 +122,26 @@ class TestAirbnbDetailTTL:
 
   async def test_fresh_detail_served_from_cache(self):
     client = _airbnb_client()
-    client._detail_cache['C1'] = {'cached': True}
-    client._detail_fetched_at['C1'] = time.monotonic()
+    client._details.set('C1', {'cached': True})
     self._stub_gql(client, {'fresh': True})
 
     assert await client._fetch_one_detail('C1') == {'cached': True}
 
   async def test_expired_detail_refetches(self):
     client = _airbnb_client()
-    client._detail_cache['C1'] = {'cached': True}
-    client._detail_fetched_at['C1'] = time.monotonic() + _EXPIRED
+    client._details._data['C1'] = {'cached': True}
+    client._details._fetched_at['C1'] = time.monotonic() + _EXPIRED
     self._stub_gql(client, {'fresh': True})
 
     assert await client._fetch_one_detail('C1') == {'fresh': True}
-    assert client._detail_cache['C1'] == {'fresh': True}
+    assert client._details.get('C1') == {'fresh': True}
 
 
 class TestBookingMonthTTL:
 
   async def test_fresh_month_served_from_cache(self):
     client = _booking_client()
-    key = (7, 2026)
-    client._month_data[key] = {'2026-07-10': _day_info()}
-    client._month_fetched_at[key] = time.monotonic()
+    _seed(client._months, (7, 2026), {'2026-07-10': _day_info()})
 
     calls = []
     async def _fetch(m, y):
@@ -149,14 +149,13 @@ class TestBookingMonthTTL:
       return {}
     client._fetch_month_calendar = _fetch
 
-    await client._get_month_calendar(7, 2026)
+    await client._months.get(7, 2026)
     assert calls == []
 
   async def test_expired_month_refetches_and_evicts_dayinfo_cache(self):
     client = _booking_client()
     key = (7, 2026)
-    client._month_data[key] = {'2026-07-10': _day_info()}
-    client._month_fetched_at[key] = time.monotonic() + _EXPIRED
+    _seed(client._months, key, {'2026-07-10': _day_info()}, age=_EXPIRED)
     client._cache['2026-07-10'] = _day_info('2026-07-10')
     client._cache['2026-08-01'] = _day_info('2026-08-01')  # different month — must survive
 
@@ -166,22 +165,21 @@ class TestBookingMonthTTL:
       return {'2026-07-10': _day_info()}
     client._fetch_month_calendar = _fetch
 
-    await client._get_month_calendar(7, 2026)
+    await client._months.get(7, 2026)
     assert calls == [(7, 2026)]
     assert '2026-07-10' not in client._cache
     assert '2026-08-01' in client._cache
-    assert key in client._month_fetched_at
+    assert key in client._months._fetched_at
 
   async def test_missing_timestamp_treated_as_expired(self):
     client = _booking_client()
-    key = (7, 2026)
-    client._month_data[key] = {'2026-07-10': _day_info()}
+    client._months._data[(7, 2026)] = {'2026-07-10': _day_info()}
 
     calls = []
     async def _fetch(m, y):
       calls.append((m, y))
-      return {}
+      return {'2026-07-10': _day_info()}  # priced result — must not trip the suspect retry
     client._fetch_month_calendar = _fetch
 
-    await client._get_month_calendar(7, 2026)
+    await client._months.get(7, 2026)
     assert calls == [(7, 2026)]