Add a periodic deposit reconciler, and stop losing subscribe tasks (B-30)
Deposits were credited exclusively by scripthash-change notifications, with nothing re-verifying a user's balance against the chain if a subscription was ever silently lost. address_for_new_user's subscribe was fire-and-forget: the task wasn't retained, so it could be garbage-collected mid-flight, and any failure (including self.client turning None between the check and the task running) vanished into asyncio's default unretrieved-exception handler instead of being logged anywhere. On an otherwise healthy, long-lived connection there may be no reconnect for days to re-subscribe everyone, so a user in that state never saw their deposits. address_for_new_user now retains the task and logs its exception if it fails. New app/deposits/reconcile.py adds DepositReconciler, a periodic sweep (every 5 minutes, gated on the Electrum client being connected, same shape as tx/reconcile.py) that round-robins over every user and calls the listener's own refresh_user (renamed from _refresh_user since it's now called from outside the class) - so the notification-driven and periodic paths can never behave differently. Deliberately sweeps every user rather than only ones missing from the in-memory scripthash map, since that map can't tell "never subscribed" apart from "subscribed, but the server stopped delivering notifications for it". Wired into app/main.py's lifespan alongside the other three background reconcilers. Suite grows from 176 to 182 tests. BUGS.md moves B-30 to Previously fixed.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"""Periodic safety net for deposit crediting and external-spend detection (B-30),
|
||||
independent of scripthash-change notifications.
|
||||
|
||||
Those notifications are the fast path, but nothing else re-verifies a user's
|
||||
balance against the chain if one is ever silently lost: `address_for_new_user`'s
|
||||
subscribe is best-effort (its own failure just logs, see electrum/listener.py),
|
||||
and on an otherwise healthy, long-lived connection there may be no reconnect for
|
||||
days — the only other event that re-subscribes everyone from scratch. Without
|
||||
this, a single lost subscription meant that user's deposits were never credited,
|
||||
indefinitely.
|
||||
|
||||
This mirrors app/tx/reconcile.py's shape (a periodic sweep gated on the Electrum
|
||||
client being connected) but reuses ElectrumListener.refresh_user directly rather
|
||||
than re-implementing crediting/spend-detection, so the notification-driven and
|
||||
periodic paths can never behave differently from each other.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.db.models import User
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SWEEP_INTERVAL_SECONDS = 300
|
||||
|
||||
|
||||
class DepositReconciler:
|
||||
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
|
||||
self._session_factory = session_factory
|
||||
self._listener = listener
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
|
||||
if self._listener.client is None:
|
||||
continue
|
||||
try:
|
||||
await self._sweep_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("deposit reconciliation sweep failed")
|
||||
|
||||
async def _sweep_once(self) -> None:
|
||||
"""Round-robins over every user's address rather than only ones missing
|
||||
from the listener's in-memory `_scripthash_to_user` map: that map can't
|
||||
tell "never subscribed" apart from "subscribed, but this server silently
|
||||
stopped delivering notifications for it" — exactly the failure mode this
|
||||
exists to catch. One user failing (a transient network hiccup) must not
|
||||
stop the sweep from reaching the rest, mirroring poll_once's per-item
|
||||
isolation in tx/confirmation.py.
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
users = (await session.scalars(select(User))).all()
|
||||
|
||||
for user in users:
|
||||
if self._listener.client is None:
|
||||
return # connection dropped mid-sweep; the next reconnect's own _subscribe_all_users covers everyone
|
||||
scripthash = address_to_scripthash(user.address)
|
||||
try:
|
||||
await self._listener.refresh_user(user.id, scripthash)
|
||||
except Exception:
|
||||
logger.exception("deposit reconciliation failed for user_id=%s", user.id)
|
||||
Reference in New Issue
Block a user