Bläddra i källkod

service clients: 10-minute TTL on calendar and detail caches

The month caches exist to dedupe requests within one operation, not to
serve minutes-old data for future use — but with no expiry, an upstream
change (e.g. a canceled reservation) could sit in a long-running server's
cache all day, feeding stale data to diffs and periodic syncs until a
restart or incidental cache clear.

Timestamp each cached month (Airbnb _raw_months, Booking _month_data) and
Airbnb detail-cache entry with time.monotonic(); evict and refetch entries
older than CACHE_TTL (600s), including the derived per-date DayInfo cache
for that month. Missing timestamps count as expired. The _had_bookings
stale-replica guard is unchanged and now shares an _evict_month helper.
ZiB 3 månader sedan
förälder
incheckning
4b3e4b79fb

+ 25 - 8
room_cal/service_api/_airbnb.py

@@ -4,6 +4,7 @@ import json
 import os
 import random
 import string
+import time
 import uuid
 from datetime import date, datetime, timedelta
 from zoneinfo import ZoneInfo
@@ -15,7 +16,7 @@ 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 month_bounds, months_in_range
+from ._utils import CACHE_TTL, month_bounds, months_in_range
 from .credentials import is_stale, mark_stale
 from .types import CalendarEvent, DayInfo, ServiceAuthError, Reservation
 
@@ -131,6 +132,7 @@ class _AirbnbClient:
     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).
@@ -139,6 +141,7 @@ class _AirbnbClient:
     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
@@ -157,7 +160,9 @@ class _AirbnbClient:
     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._booked_nets.clear()
 
   def _reload_from_env(self) -> None:
@@ -322,23 +327,31 @@ class _AirbnbClient:
         _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:
+    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.
-      if key in self._had_bookings and not any(d.get('reservationData') for d in cached):
+      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,
         )
-        del self._raw_months[key]
-        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]
+        self._evict_month(key)
         forced_refetch = True
       else:
         return cached
@@ -356,6 +369,7 @@ class _AirbnbClient:
       # 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]:
@@ -561,7 +575,9 @@ class _AirbnbClient:
 
   async def _fetch_one_detail(self, code: str) -> dict:
     if code in self._detail_cache:
-      return self._detail_cache[code]
+      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]
     try:
       resp = await self._gql(
         'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
@@ -576,6 +592,7 @@ class _AirbnbClient:
           .get('hostingDetails', {}).get('stayHostingDetails', {})
     )
     self._detail_cache[code] = det
+    self._detail_fetched_at[code] = time.monotonic()
     return det
 
   def _global_thread_id(self, thread_id: str) -> str:

+ 12 - 2
room_cal/service_api/_booking.py

@@ -12,7 +12,7 @@ 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 month_bounds, months_in_range
+from ._utils import CACHE_TTL, month_bounds, months_in_range
 from .credentials import clear_stale, is_stale, mark_stale, set_credentials
 from .types import DayInfo, ServiceAuthError, Reservation
 
@@ -169,6 +169,7 @@ class _BookingClient:
     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._refresh_lock = asyncio.Lock()
     self._refresh_task: asyncio.Task | None = None
@@ -200,6 +201,7 @@ class _BookingClient:
     self._cache.clear()
     self._fetched_months.clear()
     self._month_data.clear()
+    self._month_fetched_at.clear()
 
   def _reload_from_env(self) -> None:
     env_file = data_home() / 'booking.env'
@@ -540,7 +542,14 @@ class _BookingClient:
   async def _get_month_calendar(self, month: int, year: int) -> dict[str, DayInfo]:
     key = (month, year)
     if key in self._month_data:
-      return self._month_data[key]
+      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))
@@ -550,6 +559,7 @@ class _BookingClient:
     finally:
       self._months_inflight.pop(key, None)
     self._month_data[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]:

+ 4 - 0
room_cal/service_api/_utils.py

@@ -1,5 +1,9 @@
 from datetime import date, timedelta
 
+# Client data caches exist to dedupe requests within one operation, not to serve
+# minutes-old data later — entries older than this are refetched on next access.
+CACHE_TTL = 600.0
+
 
 def month_bounds(month: int, year: int) -> tuple[str, str]:
   start = date(year, month, 1)

+ 5 - 0
tests/test_reservation_guards.py

@@ -6,6 +6,7 @@
 3. fetch_reservations mass-deletion guard — skip deletions when a single run
    would remove more than _MAX_DELETIONS_PER_RUN reservations for a service.
 """
+import time
 from datetime import date, timedelta
 
 import pytest
@@ -133,6 +134,7 @@ class TestHadBookings:
     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()
 
     calls = []
     async def _fetch(m, y):
@@ -149,6 +151,7 @@ class TestHadBookings:
     client = _airbnb_client()
     key = (7, 2026)
     client._raw_months[key] = _days(booked=False)
+    client._month_fetched_at[key] = time.monotonic()
 
     calls = []
     async def _fetch(m, y):
@@ -165,6 +168,7 @@ class TestHadBookings:
     key = (7, 2026)
     client._had_bookings.add(key)
     client._raw_months[key] = _days(booked=False)
+    client._month_fetched_at[key] = time.monotonic()
 
     async def _fetch(m, y): return _days(booked=False)  # still 0 on refetch
     client._fetch_month_days = _fetch
@@ -181,6 +185,7 @@ class TestHadBookings:
     key = (7, 2026)
     client._had_bookings.add(key)
     client._raw_months[key] = _days(booked=False)
+    client._month_fetched_at[key] = time.monotonic()
     client._fetched_months.add(key)
     client._cache['2026-07-10'] = _dummy
     client._cache['2026-08-01'] = _dummy  # different month — must survive

+ 187 - 0
tests/test_service_cache_ttl.py

@@ -0,0 +1,187 @@
+"""Tests for the service-client cache TTL (CACHE_TTL in service_api/_utils.py).
+
+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.
+"""
+import time
+
+from room_cal.service_api._utils import CACHE_TTL
+from room_cal.service_api.types import DayInfo
+
+
+def _airbnb_client():
+  from room_cal.service_api._airbnb import _AirbnbClient
+  return _AirbnbClient(cookies='test', listing_id='12345678')
+
+
+def _booking_client():
+  from room_cal.service_api._booking import _BookingClient
+  return _BookingClient(cookies='test', session='ses', hotel_id=1,
+                        account_id=1, room_id=1, rate_id='r1')
+
+
+def _days(booked=False):
+  day = {'day': '2026-07-10', 'priceData': {'price': 100}, 'available': True}
+  if booked:
+    day['reservationData'] = {'confirmationCode': 'TEST123'}
+  return [day]
+
+
+def _day_info(d='2026-07-10'):
+  return DayInfo(date=d, price=100, available=True, booked=False, rooms_to_sell=1)
+
+
+_EXPIRED = -(CACHE_TTL + 1)
+
+
+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()
+
+    calls = []
+    async def _fetch(m, y):
+      calls.append((m, y))
+      return _days()
+    client._fetch_month_days = _fetch
+
+    await client._get_month_days(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
+
+    calls = []
+    async def _fetch(m, y):
+      calls.append((m, y))
+      return _days(booked=True)
+    client._fetch_month_days = _fetch
+
+    result = await client._get_month_days(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()
+
+    calls = []
+    async def _fetch(m, y):
+      calls.append((m, y))
+      return _days()
+    client._fetch_month_days = _fetch
+
+    await client._get_month_days(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
+    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
+
+    async def _fetch(m, y): return _days()
+    client._fetch_month_days = _fetch
+
+    await client._get_month_days(7, 2026)
+    assert key not in client._fetched_months
+    assert '2026-07-10' not in client._cache
+    assert '2026-08-01' in client._cache
+
+  async def test_refetch_records_fresh_timestamp(self):
+    client = _airbnb_client()
+    key = (7, 2026)
+    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
+
+
+class TestAirbnbDetailTTL:
+
+  def _stub_gql(self, client, det):
+    async def _gql(op, h, variables, referer):
+      return {'data': {'presentation': {'hostingDetails': {'stayHostingDetails': det}}}}
+    client._gql = _gql
+
+  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()
+    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
+    self._stub_gql(client, {'fresh': True})
+
+    assert await client._fetch_one_detail('C1') == {'fresh': True}
+    assert client._detail_cache['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()
+
+    calls = []
+    async def _fetch(m, y):
+      calls.append((m, y))
+      return {}
+    client._fetch_month_calendar = _fetch
+
+    await client._get_month_calendar(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
+    client._cache['2026-07-10'] = _day_info('2026-07-10')
+    client._cache['2026-08-01'] = _day_info('2026-08-01')  # different month — must survive
+
+    calls = []
+    async def _fetch(m, y):
+      calls.append((m, y))
+      return {'2026-07-10': _day_info()}
+    client._fetch_month_calendar = _fetch
+
+    await client._get_month_calendar(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
+
+  async def test_missing_timestamp_treated_as_expired(self):
+    client = _booking_client()
+    key = (7, 2026)
+    client._month_data[key] = {'2026-07-10': _day_info()}
+
+    calls = []
+    async def _fetch(m, y):
+      calls.append((m, y))
+      return {}
+    client._fetch_month_calendar = _fetch
+
+    await client._get_month_calendar(7, 2026)
+    assert calls == [(7, 2026)]