Просмотр исходного кода

Guard reservation deletions against service failures and stale cache

Two related fixes to prevent mass reservation deletion + wifi key
regeneration when a service has a transient fault:

1. fetch_reservations: skip deletions per-service when that service
   either threw an exception (Booking.com 502 etc.) or returned 0
   reservations while we have stored upcoming ones. Logs ERROR in both
   cases. ServiceAPI.get_reservations gains _out_errors kwarg to surface
   which services failed.

2. _airbnb.py: add _had_bookings set (survives clear_cache) tracking
   months that have had booked days. _get_month_days forces a refetch
   when the cache shows 0 booked for a month in _had_bookings — the
   signal that an eventual-consistency stale replica was served after a
   credential refresh cleared the cache.
ZiB 4 месяцев назад
Родитель
Сommit
762812b9d8

+ 31 - 1
room_cal/actions/fetch_reservations.py

@@ -1,4 +1,5 @@
 import asyncio
+import logging
 from datetime import date, timedelta
 from typing import TYPE_CHECKING
 
@@ -9,6 +10,8 @@ from room_cal._utils import FetchResult, safe_json_loads, iter_dates, services a
 if TYPE_CHECKING:
   from room_cal.service_api import ServiceAPI
 
+_log = logging.getLogger(__name__)
+
 ACTION_SPEC = ('fetch-reservations', [])
 
 
@@ -66,12 +69,39 @@ async def fetch_reservations(
       if rid:
         existing_by_id[rid] = (e, d)
 
-  service_reservations = await api.get_reservations(start_date, end_date)
+  errored_services: set[str] = set()
+  service_reservations = await api.get_reservations(start_date, end_date, _out_errors=errored_services)
   service_ids = {r['id'] for r in service_reservations}
 
+  # Per-service guard: don't delete a local reservation when its service threw an exception —
+  # the missing ID could be a transient failure, not a genuine cancellation.
+  returned_services = {r['service'] for r in service_reservations}
+  stored_by_service: dict[str, int] = {}
+  for data in (d for _, d in existing_by_id.values()):
+    svc = data.get('service', '')
+    if svc:
+      stored_by_service[svc] = stored_by_service.get(svc, 0) + 1
+  guarded_services: set[str] = set()
+  for svc, count in stored_by_service.items():
+    if svc in errored_services:
+      _log.error(
+        'fetch_reservations: %r errored; skipping %d deletion(s) to avoid false purge',
+        svc, count,
+      )
+      guarded_services.add(svc)
+    elif svc not in returned_services:
+      _log.error(
+        'fetch_reservations: %r returned 0 reservations but %d are stored; '
+        'skipping deletion(s) — verify service is healthy',
+        svc, count,
+      )
+      guarded_services.add(svc)
+
   changed = False
 
   for rid, (event, data) in existing_by_id.items():
+    if data.get('service') in guarded_services:
+      continue
     if rid not in service_ids:
       changed = True
       await res_cal.delete(event)

+ 25 - 1
room_cal/service_api/_airbnb.py

@@ -132,6 +132,9 @@ class _AirbnbClient:
     self._fetched_months: set[tuple[int, int]] = set()
     self._raw_months: dict[tuple[int, int], list[dict]] = {}
     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._refresh_lock = asyncio.Lock()
     self._refresh_task: asyncio.Task | None = None
     self._http = httpx.AsyncClient(timeout=10.0)
@@ -321,8 +324,24 @@ class _AirbnbClient:
 
   async def _get_month_days(self, month: int, year: int) -> list[dict]:
     key = (month, year)
+    forced_refetch = False
     if key in self._raw_months:
-      return self._raw_months[key]
+      cached = self._raw_months[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):
+        _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]
+        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))
@@ -331,6 +350,11 @@ class _AirbnbClient:
       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
     return result
 

+ 10 - 1
room_cal/service_api/client.py

@@ -225,10 +225,17 @@ class ServiceAPI:
         result[d] = closed
     return result
 
-  async def get_reservations(self, start_date: date, end_date: date) -> list[dict]:
+  async def get_reservations(
+    self,
+    start_date: date,
+    end_date: date,
+    *,
+    _out_errors: set | None = None,
+  ) -> list[dict]:
     """Return reservations from all services, sorted by start_date.
 
     Each entry: {service, start_date, end_date, id, guest_name}
+    Pass a set to _out_errors to collect the keys of services that failed (returned an exception).
     """
     ds_from = start_date.isoformat()
     ds_to = end_date.isoformat()
@@ -238,6 +245,8 @@ class ServiceAPI:
     result: list[dict] = []
     for key, reservations in all_res.items():
       if reservations is None:
+        if _out_errors is not None:
+          _out_errors.add(key)
         continue
       for r in reservations:  # type: ignore[union-attr]
         result.append({