| """Background tracker — periodic coroutines that keep billing state consistent. |
| |
| The router keeps balances in memory (hot path). This module runs background |
| coroutines that persist state to gists and (later) watch for payments. |
| |
| Coroutines: |
| usage_flusher (30s) — write cached balances back to per-user gists |
| payment_watcher(60s) — scan deposit addresses for incoming ETH → credit [Phase 5] |
| price_refresher( 5m) — refresh ETH/USD price [Phase 6] |
| |
| Only the flusher runs in Phase 4; the others are stubbed for later phases. |
| All coroutines are resilient: exceptions are logged, never crash the task. |
| """ |
| from __future__ import annotations |
|
|
| import asyncio |
| import logging |
|
|
| from . import cache, registry |
|
|
| logger = logging.getLogger("router.tracker") |
|
|
| FLUSH_INTERVAL = 30.0 |
| PAYMENT_INTERVAL = 60.0 |
| PRICE_INTERVAL = 300.0 |
| SWEEP_INTERVAL = 21600.0 |
|
|
|
|
| class Tracker: |
| """Manages the background billing coroutines for the lifetime of the app.""" |
|
|
| def __init__(self): |
| self._tasks: list[asyncio.Task] = [] |
| self._running = False |
|
|
| def start(self) -> None: |
| """Launch all background coroutines (called once at app startup).""" |
| if self._running: |
| return |
| self._running = True |
| self._tasks = [ |
| asyncio.create_task(self._run_flusher(), name="usage_flusher"), |
| asyncio.create_task(self._run_payments(), name="payment_watcher"), |
| asyncio.create_task(self._run_price(), name="price_refresher"), |
| asyncio.create_task(self._run_sweeper(), name="stale_sweeper"), |
| ] |
| logger.info("tracker started: %d coroutines", len(self._tasks)) |
|
|
| async def stop(self) -> None: |
| """Cancel all coroutines cleanly (called on app shutdown).""" |
| for task in self._tasks: |
| task.cancel() |
| for task in self._tasks: |
| try: |
| await task |
| except asyncio.CancelledError: |
| pass |
| self._tasks = [] |
| self._running = False |
| logger.info("tracker stopped") |
|
|
| async def _run_flusher(self) -> None: |
| """Periodically flush cached balances to per-user gists (every 30s).""" |
| while True: |
| try: |
| await asyncio.sleep(FLUSH_INTERVAL) |
| written = await cache.flush() |
| if written: |
| logger.info("flusher: persisted %d user balances", written) |
| except asyncio.CancelledError: |
| raise |
| except Exception as exc: |
| logger.error("flusher error: %s", exc) |
|
|
| async def _run_payments(self) -> None: |
| """Poll deposit addresses on Base for incoming ETH → credits (every 60s).""" |
| from . import payments |
| while True: |
| try: |
| await asyncio.sleep(PAYMENT_INTERVAL) |
| credited = await payments.scan_all() |
| if credited: |
| logger.info("payments: %d deposits processed", credited) |
| except asyncio.CancelledError: |
| raise |
| except Exception as exc: |
| logger.error("payment watcher error: %s", exc) |
|
|
| async def _run_price(self) -> None: |
| """Refresh the ETH/USD price from CoinGecko and cache it (every 5 min).""" |
| from . import price |
| from ..forwarder import get_shared_client |
| while True: |
| try: |
| await asyncio.sleep(PRICE_INTERVAL) |
| client = get_shared_client() |
| usd = await price.fetch_eth_usd(client) |
| if usd and usd > 0: |
| await registry.set_price(usd) |
| logger.info("price refreshed: ETH @ $%.2f", usd) |
| except asyncio.CancelledError: |
| raise |
| except Exception as exc: |
| logger.error("price refresher error: %s", exc) |
|
|
| async def _run_sweeper(self) -> None: |
| """Reap dormant accounts: users with 0 credits + no activity in 30 days. |
| |
| Marks them 'dormant' (not deleted — their balance + history are kept so a |
| returning user can still deposit and resume). This keeps the registry lean |
| and the payment-watcher loop from polling dead addresses forever. |
| """ |
| import time |
| DORMANT_SECONDS = 30 * 24 * 3600 |
| while True: |
| try: |
| await asyncio.sleep(SWEEP_INTERVAL) |
| reg = await registry.load() |
| now = time.time() |
| swept = 0 |
| for u in reg.get("users", []): |
| if u.get("status") != "active": |
| continue |
| bal = await cache.get_balance(u["user_id"]) |
| if bal > 0: |
| continue |
| last = u.get("last_active_epoch", 0) |
| if not last: |
| continue |
| if (now - last) < DORMANT_SECONDS: |
| continue |
| u["status"] = "dormant" |
| swept += 1 |
| if swept: |
| await registry.save(reg) |
| logger.info("sweeper: %d dormant accounts marked", swept) |
| except asyncio.CancelledError: |
| raise |
| except Exception as exc: |
| logger.error("sweeper error: %s", exc) |
|
|
|
|
| |
| TRACKER = Tracker() |
|
|