mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
perf: cut mint requests per payout cycle
This commit is contained in:
+22
-10
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user