perf: cut mint requests per payout cycle

This commit is contained in:
9qeklajc
2026-09-18 01:31:16 +02:00
parent 69a79a1e2c
commit b475d269dc
3 changed files with 121 additions and 13 deletions
+22 -10
View File
@@ -1165,7 +1165,15 @@ async def get_wallet(
retry_on_rate_limit: bool = True, retry_on_rate_limit: bool = True,
force_reload: bool = False, force_reload: bool = False,
load_proofs: bool = True, load_proofs: bool = True,
force_reload_proofs: bool = False,
) -> Wallet: ) -> Wallet:
"""Return the cached wallet for a mint/unit, refreshing state as needed.
``force_reload`` refreshes both mint keysets and proofs from scratch.
``force_reload_proofs`` only bypasses the proof reload interval, keeping
the cached keysets: proofs live in the local DB, so this costs no mint
requests beyond the normal keyset refresh interval.
"""
global _wallets, _wallet_last_load, _wallet_last_mint_load, _wallet_load_locks global _wallets, _wallet_last_load, _wallet_last_mint_load, _wallet_load_locks
id = f"{mint_url}_{unit}" id = f"{mint_url}_{unit}"
lock = _wallet_load_locks.setdefault(id, asyncio.Lock()) lock = _wallet_load_locks.setdefault(id, asyncio.Lock())
@@ -1197,6 +1205,7 @@ async def get_wallet(
last_proof_load = _wallet_last_load.get(id) last_proof_load = _wallet_last_load.get(id)
if ( if (
force_reload force_reload
or force_reload_proofs
or last_proof_load is None or last_proof_load is None
or now - last_proof_load or now - last_proof_load
>= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS >= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS
@@ -1545,12 +1554,20 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
try: try:
# Runs under wallet_operation_guard; a cached wallet may carry a proof # Runs under wallet_operation_guard; a cached wallet may carry a proof
# snapshot up to 30s stale from another process's reservation, so the # snapshot up to 30s stale from another process's reservation, so the
# cross-process lock is only safe with a fresh reload. # cross-process lock is only safe with a fresh proof reload. Proofs
wallet = await get_wallet(mint_url, unit, force_reload=True) # come from the local DB; keysets stay on their normal refresh interval
# so each cycle does not re-fetch every keyset from the mint.
wallet = await get_wallet(mint_url, unit, force_reload_proofs=True)
proofs = get_proofs_per_mint_and_unit(wallet, mint_url, unit, not_reserved=True) proofs = get_proofs_per_mint_and_unit(wallet, mint_url, unit, not_reserved=True)
if not proofs: min_amount = (
# Nothing to pay out, so skip the settle delay rather than hold the settings.min_payout_sat
# cross-process guard (and block credits) for a wallet with no funds. if unit == "sat"
else _sats_to_msats(settings.min_payout_sat)
)
if sum(proof.amount for proof in proofs) <= min_amount:
# Nothing payable even before subtracting liabilities, so skip the
# proof-state check and settle delay rather than spend mint quota
# and hold the cross-process guard (blocking credits) for nothing.
return return
proofs = await slow_filter_spend_proofs(proofs, wallet) proofs = await slow_filter_spend_proofs(proofs, wallet)
await asyncio.sleep(5) await asyncio.sleep(5)
@@ -1582,11 +1599,6 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
user_balance = _msats_to_sats_ceil(user_balance) user_balance = _msats_to_sats_ceil(user_balance)
proofs_balance = sum(proof.amount for proof in proofs) proofs_balance = sum(proof.amount for proof in proofs)
available_balance = proofs_balance - user_balance available_balance = proofs_balance - user_balance
min_amount = (
settings.min_payout_sat
if unit == "sat"
else _sats_to_msats(settings.min_payout_sat)
)
if available_balance > min_amount: if available_balance > min_amount:
amount_received = await raw_send_to_lnurl( amount_received = await raw_send_to_lnurl(
wallet, wallet,
+85 -2
View File
@@ -156,7 +156,7 @@ async def test_periodic_payout_isolates_failing_mint() -> None:
from routstr.core.settings import settings from routstr.core.settings import settings
async def _get_wallet( async def _get_wallet(
mint_url: str, unit: str, force_reload: bool = False mint_url: str, unit: str, force_reload_proofs: bool = False
) -> MagicMock: ) -> MagicMock:
if mint_url == "http://bad:3338": if mint_url == "http://bad:3338":
raise RuntimeError("mint unreachable") raise RuntimeError("mint unreachable")
@@ -226,7 +226,9 @@ async def test_periodic_payout_handles_session_creation_failure() -> None:
patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())), patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())),
patch( patch(
"routstr.wallet.get_proofs_per_mint_and_unit", "routstr.wallet.get_proofs_per_mint_and_unit",
MagicMock(return_value=[MagicMock(amount=100_000)]), # Above min_payout_sat for both sat and msat wallets so neither
# unit is skipped before the liability session is opened.
MagicMock(return_value=[MagicMock(amount=1_000_000)]),
), ),
patch( patch(
"routstr.wallet.slow_filter_spend_proofs", "routstr.wallet.slow_filter_spend_proofs",
@@ -255,3 +257,84 @@ async def test_payout_units_excludes_units_the_sender_cannot_pay() -> None:
AsyncMock(return_value=["usd", "sat", "eur", "msat"]), AsyncMock(return_value=["usd", "sat", "eur", "msat"]),
): ):
assert await _payout_units("http://mint:3338") == ["sat", "msat"] assert await _payout_units("http://mint:3338") == ["sat", "msat"]
@pytest.mark.asyncio
async def test_periodic_payout_reloads_proofs_without_refetching_keysets() -> None:
"""The payout cycle must not force a full keyset refresh from the mint."""
from routstr.core.settings import settings
get_wallet = AsyncMock(return_value=MagicMock())
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch.object(settings, "primary_mint", "http://mint:3338"),
patch.object(settings, "receive_ln_address", "owner@ln.tld"),
patch.object(settings, "payout_interval_seconds", _INTERVAL),
patch.object(settings, "min_payout_sat", 10),
patch("routstr.wallet.asyncio.sleep", _one_cycle_sleep()),
patch("routstr.wallet.db.create_session", _fake_session),
patch(
"routstr.wallet._get_supported_mint_units",
AsyncMock(return_value=["sat"]),
),
patch("routstr.wallet.get_wallet", get_wallet),
patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
MagicMock(return_value=[MagicMock(amount=100_000)]),
),
patch(
"routstr.wallet.slow_filter_spend_proofs",
AsyncMock(side_effect=lambda proofs, wallet: proofs),
),
patch(
"routstr.wallet.db.total_user_liability",
AsyncMock(return_value=0),
),
patch("routstr.wallet.raw_send_to_lnurl", AsyncMock(return_value=1000)),
):
with pytest.raises(_LoopBreak):
await periodic_payout()
get_wallet.assert_awaited_once_with(
"http://mint:3338", "sat", force_reload_proofs=True
)
@pytest.mark.asyncio
async def test_periodic_payout_skips_proof_state_check_below_min_payout() -> None:
"""A wallet at or below min_payout_sat costs no mint requests."""
from routstr.core.settings import settings
check_state = AsyncMock(side_effect=lambda proofs, wallet: proofs)
raw_send = AsyncMock(return_value=1000)
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch.object(settings, "primary_mint", "http://mint:3338"),
patch.object(settings, "receive_ln_address", "owner@ln.tld"),
patch.object(settings, "payout_interval_seconds", _INTERVAL),
patch.object(settings, "min_payout_sat", 210),
patch("routstr.wallet.asyncio.sleep", _one_cycle_sleep()),
patch("routstr.wallet.db.create_session", _fake_session),
patch(
"routstr.wallet._get_supported_mint_units",
AsyncMock(return_value=["sat"]),
),
patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())),
patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
MagicMock(return_value=[MagicMock(amount=200)]),
),
patch("routstr.wallet.slow_filter_spend_proofs", check_state),
patch(
"routstr.wallet.db.total_user_liability",
AsyncMock(return_value=0),
),
patch("routstr.wallet.raw_send_to_lnurl", raw_send),
):
with pytest.raises(_LoopBreak):
await periodic_payout()
check_state.assert_not_awaited()
raw_send.assert_not_awaited()
+14 -1
View File
@@ -143,6 +143,19 @@ async def test_get_wallet_force_reload_bypasses_reload_interval() -> None:
assert mock_wallet.load_proofs.await_count == 2 assert mock_wallet.load_proofs.await_count == 2
@pytest.mark.asyncio
async def test_get_wallet_force_reload_proofs_keeps_cached_keysets() -> None:
from routstr.wallet import get_wallet
mock_wallet = Mock(load_mint=AsyncMock(), load_proofs=AsyncMock())
with patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)):
await get_wallet("http://mint:3338", "sat")
await get_wallet("http://mint:3338", "sat", force_reload_proofs=True)
assert mock_wallet.load_mint.await_count == 1
assert mock_wallet.load_proofs.await_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_public_recieve_token_holds_wallet_operation_guard() -> None: async def test_public_recieve_token_holds_wallet_operation_guard() -> None:
inside_guard = False inside_guard = False
@@ -2017,7 +2030,7 @@ async def test_payout_reloads_wallet_snapshot_under_guard() -> None:
await _payout_mint_and_unit("https://mint.example.com", "sat") await _payout_mint_and_unit("https://mint.example.com", "sat")
mock_get_wallet.assert_awaited_once_with( mock_get_wallet.assert_awaited_once_with(
"https://mint.example.com", "sat", force_reload=True "https://mint.example.com", "sat", force_reload_proofs=True
) )