perf(loyalty): Phase 3 — batched expiration + wallet sync backoff
Some checks failed
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / ruff (push) Successful in 12s
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has started running

Phase 3 of the production launch plan: task reliability improvements
to prevent DB lock issues at scale and handle transient wallet API
failures gracefully.

- 3.1 Batched point expiration: rewrite per-card Python loop to chunked
  processing (LIMIT 500 FOR UPDATE SKIP LOCKED). Each chunk commits
  independently, releasing row locks before processing the next batch.
  Notifications sent after commit (outside lock window). Warning emails
  also chunked with same pattern.
- 3.2 Wallet sync exponential backoff: replace time.sleep(2) single
  retry with 4 attempts using [1s, 4s, 16s] backoff delays. Per-card
  try/except ensures one failing card doesn't block the batch.
  Failed card IDs logged for observability.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 19:55:39 +02:00
parent 52b78ce346
commit fde58bea06
2 changed files with 250 additions and 193 deletions

View File

@@ -4,14 +4,22 @@ Wallet synchronization task.
Handles syncing loyalty card data to Google Wallet and Apple Wallet
for cards that may have missed real-time updates.
Uses exponential backoff (1s, 4s, 16s) per card to handle transient
API failures without blocking the entire batch.
"""
import logging
import time
from celery import shared_task
logger = logging.getLogger(__name__)
# Exponential backoff delays in seconds: 1s, 4s, 16s
_RETRY_DELAYS = [1, 4, 16]
_MAX_ATTEMPTS = len(_RETRY_DELAYS) + 1 # 4 total attempts
@shared_task(name="loyalty.sync_wallet_passes")
def sync_wallet_passes() -> dict:
@@ -35,7 +43,6 @@ def sync_wallet_passes() -> dict:
# Find cards with transactions in the last hour that have wallet IDs
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
# Get card IDs with recent transactions
recent_tx_card_ids = (
db.query(LoyaltyTransaction.card_id)
.filter(LoyaltyTransaction.transaction_at >= one_hour_ago)
@@ -51,9 +58,9 @@ def sync_wallet_passes() -> dict:
"cards_checked": 0,
"google_synced": 0,
"apple_synced": 0,
"failed_card_ids": [],
}
# Get cards with wallet integrations
cards = (
db.query(LoyaltyCard)
.filter(
@@ -69,31 +76,21 @@ def sync_wallet_passes() -> dict:
failed_card_ids = []
for card in cards:
synced = False
for attempt in range(2): # 1 retry
try:
results = wallet_service.sync_card_to_wallets(db, card)
if results.get("google_wallet"):
google_synced += 1
if results.get("apple_wallet"):
apple_synced += 1
synced = True
break
except Exception as e:
if attempt == 0:
logger.warning(
f"Failed to sync card {card.id} (attempt 1/2), "
f"retrying in 2s: {e}"
)
import time
time.sleep(2)
else:
logger.error(
f"Failed to sync card {card.id} after 2 attempts: {e}"
)
if not synced:
success, google, apple = _sync_card_with_backoff(
wallet_service, db, card
)
if success:
google_synced += google
apple_synced += apple
else:
failed_card_ids.append(card.id)
if failed_card_ids:
logger.error(
f"Wallet sync: {len(failed_card_ids)} cards failed after "
f"{_MAX_ATTEMPTS} attempts each: {failed_card_ids}"
)
logger.info(
f"Wallet sync complete: {len(cards)} cards checked, "
f"{google_synced} Google, {apple_synced} Apple, "
@@ -113,6 +110,37 @@ def sync_wallet_passes() -> dict:
return {
"status": "error",
"error": str(e),
"failed_card_ids": [],
}
finally:
db.close()
def _sync_card_with_backoff(wallet_service, db, card) -> tuple[bool, int, int]:
"""Sync a single card with exponential backoff.
Returns (success, google_count, apple_count).
"""
last_error = None
for attempt in range(_MAX_ATTEMPTS):
try:
results = wallet_service.sync_card_to_wallets(db, card)
google = 1 if results.get("google_wallet") else 0
apple = 1 if results.get("apple_wallet") else 0
return True, google, apple
except Exception as e:
last_error = e
if attempt < len(_RETRY_DELAYS):
delay = _RETRY_DELAYS[attempt]
logger.warning(
f"Card {card.id} sync failed (attempt {attempt + 1}/"
f"{_MAX_ATTEMPTS}), retrying in {delay}s: {e}"
)
time.sleep(delay)
logger.error(
f"Card {card.id} sync failed after {_MAX_ATTEMPTS} attempts: "
f"{last_error}"
)
return False, 0, 0