|
@@ -27,6 +27,42 @@ def _source_matches(stored: dict, domain: str, key: str, id_: str) -> bool:
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# Delay before the confirmation re-query, mirroring MonthCache's empty/priceless
|
|
|
|
|
+# suspect-retry delay — long enough for a stale read-replica lag to clear.
|
|
|
|
|
+_CONFIRM_RETRY_DELAY = 1.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _confirm_missing(
|
|
|
|
|
+ api: 'ServiceAPI',
|
|
|
|
|
+ svc: str,
|
|
|
|
|
+ date_from: date,
|
|
|
|
|
+ date_to: date,
|
|
|
|
|
+ missing_ids: set[str],
|
|
|
|
|
+) -> bool:
|
|
|
|
|
+ """Re-query a service's reservation list before trusting that stored IDs are gone.
|
|
|
|
|
+
|
|
|
|
|
+ In slow season a service legitimately having 0-1 bookings for a window is no
|
|
|
|
|
+ longer rare, so "returned none, N stored" can't be treated as inherently
|
|
|
|
|
+ suspicious on its own. A fresh re-query of the same endpoint — not the
|
|
|
|
|
+ calendar, which can be reading from the same cached month payload the first
|
|
|
|
|
+ get_reservations call was parsed from (Airbnb) or its own eventual-consistency
|
|
|
|
|
+ window (Booking) — catches a read that landed on a stale replica or lost a
|
|
|
|
|
+ race with the service's own write path, without introducing a second,
|
|
|
|
|
+ unverified data source.
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ client = api.client(svc)
|
|
|
|
|
+ except (KeyError, AttributeError):
|
|
|
|
|
+ return False
|
|
|
|
|
+ await asyncio.sleep(_CONFIRM_RETRY_DELAY)
|
|
|
|
|
+ try:
|
|
|
|
|
+ reservations = await client.get_reservations(date_from.isoformat(), date_to.isoformat())
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return False
|
|
|
|
|
+ still_present = {r.reservation_id for r in reservations} & missing_ids
|
|
|
|
|
+ return not still_present
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def _remove_reservation_closures(
|
|
async def _remove_reservation_closures(
|
|
|
closed: Closed,
|
|
closed: Closed,
|
|
|
checkin_date: date,
|
|
checkin_date: date,
|
|
@@ -86,6 +122,7 @@ async def fetch_reservations(
|
|
|
if svc:
|
|
if svc:
|
|
|
stored_by_service[svc] = stored_by_service.get(svc, 0) + 1
|
|
stored_by_service[svc] = stored_by_service.get(svc, 0) + 1
|
|
|
guarded_services: set[str] = set()
|
|
guarded_services: set[str] = set()
|
|
|
|
|
+ guarded_ids: set[str] = set()
|
|
|
for svc, count in stored_by_service.items():
|
|
for svc, count in stored_by_service.items():
|
|
|
if svc in errored_services:
|
|
if svc in errored_services:
|
|
|
_log.error(
|
|
_log.error(
|
|
@@ -94,19 +131,47 @@ async def fetch_reservations(
|
|
|
)
|
|
)
|
|
|
guarded_services.add(svc)
|
|
guarded_services.add(svc)
|
|
|
elif svc not in returned_services:
|
|
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)
|
|
|
|
|
|
|
+ missing_ids = {
|
|
|
|
|
+ rid for rid, (_, data) in existing_by_id.items() if data.get('service') == svc
|
|
|
|
|
+ }
|
|
|
|
|
+ # Per-ID authoritative check first (StayHostingDetailsQuery's isCanceled for Airbnb,
|
|
|
|
|
+ # reservationDetails' is_cancelled for Booking) — a service legitimately having 0-1
|
|
|
|
|
+ # bookings in a window is no longer rare in slow season, so "returned none" alone can't
|
|
|
|
|
+ # justify blanket-guarding the whole service. hasattr-dispatched: a client without the
|
|
|
|
|
+ # capability (api.check_reservation_status returns None for every id) falls straight
|
|
|
|
|
+ # through to the bulk re-query fallback below, unchanged from before.
|
|
|
|
|
+ check_status = getattr(api, 'check_reservation_status', None)
|
|
|
|
|
+ confirmed_canceled: set[str] = set()
|
|
|
|
|
+ unresolved: set[str] = set()
|
|
|
|
|
+ for rid in missing_ids:
|
|
|
|
|
+ status = await check_status(svc, rid) if check_status else None
|
|
|
|
|
+ if status:
|
|
|
|
|
+ confirmed_canceled.add(rid)
|
|
|
|
|
+ else:
|
|
|
|
|
+ unresolved.add(rid)
|
|
|
|
|
+ if unresolved and await _confirm_missing(api, svc, start_date, end_date, unresolved):
|
|
|
|
|
+ confirmed_canceled |= unresolved
|
|
|
|
|
+ unresolved = set()
|
|
|
|
|
+ if confirmed_canceled:
|
|
|
|
|
+ _log.info(
|
|
|
|
|
+ 'fetch_reservations: %r returned 0 reservations; %d of %d stored reservation(s) '
|
|
|
|
|
+ 'confirmed genuinely gone — proceeding with deletion(s)',
|
|
|
|
|
+ svc, len(confirmed_canceled), count,
|
|
|
|
|
+ )
|
|
|
|
|
+ if unresolved:
|
|
|
|
|
+ _log.error(
|
|
|
|
|
+ 'fetch_reservations: %r returned 0 reservations; %d of %d stored reservation(s) '
|
|
|
|
|
+ 'could not be confirmed gone — skipping deletion(s) — verify service is healthy',
|
|
|
|
|
+ svc, len(unresolved), count,
|
|
|
|
|
+ )
|
|
|
|
|
+ guarded_ids.update(unresolved)
|
|
|
|
|
|
|
|
# Mass-deletion guard: cancel a service's deletions when a single run would
|
|
# Mass-deletion guard: cancel a service's deletions when a single run would
|
|
|
# remove more than _MAX_DELETIONS_PER_RUN reservations.
|
|
# remove more than _MAX_DELETIONS_PER_RUN reservations.
|
|
|
pending_deletions: dict[str, int] = {}
|
|
pending_deletions: dict[str, int] = {}
|
|
|
for rid, (_, data) in existing_by_id.items():
|
|
for rid, (_, data) in existing_by_id.items():
|
|
|
svc = data.get('service', '')
|
|
svc = data.get('service', '')
|
|
|
- if svc in guarded_services or rid in service_ids:
|
|
|
|
|
|
|
+ if svc in guarded_services or rid in guarded_ids or rid in service_ids:
|
|
|
continue
|
|
continue
|
|
|
pending_deletions[svc] = pending_deletions.get(svc, 0) + 1
|
|
pending_deletions[svc] = pending_deletions.get(svc, 0) + 1
|
|
|
for svc, count in pending_deletions.items():
|
|
for svc, count in pending_deletions.items():
|
|
@@ -121,7 +186,7 @@ async def fetch_reservations(
|
|
|
changed = False
|
|
changed = False
|
|
|
|
|
|
|
|
for rid, (event, data) in existing_by_id.items():
|
|
for rid, (event, data) in existing_by_id.items():
|
|
|
- if data.get('service') in guarded_services:
|
|
|
|
|
|
|
+ if data.get('service') in guarded_services or rid in guarded_ids:
|
|
|
continue
|
|
continue
|
|
|
if rid not in service_ids:
|
|
if rid not in service_ids:
|
|
|
changed = True
|
|
changed = True
|