perf(loyalty): Phase 3 — batched expiration + wallet sync backoff
Some checks failed
Some checks failed
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user