172 lines
4.5 KiB
Python
172 lines
4.5 KiB
Python
import asyncio
|
|
from time import time
|
|
|
|
import pytest
|
|
from pytest_mock.plugin import MockerFixture
|
|
|
|
from lnbits.settings import Settings
|
|
from lnbits.utils.cache import Cache, Cached
|
|
|
|
key = "foo"
|
|
value = "bar"
|
|
|
|
|
|
@pytest.fixture
|
|
async def cache():
|
|
cache = Cache(interval=0.1)
|
|
|
|
task = asyncio.create_task(cache.invalidate_forever())
|
|
yield cache
|
|
task.cancel()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cache_get_set(cache):
|
|
cache.set(key, value)
|
|
assert cache.get(key) == value
|
|
assert cache.get(key, default="default") == value
|
|
assert cache.get("i-dont-exist", default="default") == "default"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cache_expiry(cache):
|
|
# gets expired by `get` call
|
|
cache.set(key, value, expiry=0.01)
|
|
await asyncio.sleep(0.02)
|
|
assert not cache.get(key)
|
|
|
|
# gets expired by invalidation task
|
|
cache.set(key, value, expiry=0.1)
|
|
await asyncio.sleep(0.2)
|
|
assert key not in cache._values
|
|
assert not cache.get(key)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cache_pop(cache):
|
|
cache.set(key, value)
|
|
assert cache.pop(key) == value
|
|
assert not cache.get(key)
|
|
assert cache.pop(key, default="a") == "a"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cache_coro(cache):
|
|
called = 0
|
|
|
|
async def test():
|
|
nonlocal called
|
|
called += 1
|
|
return called
|
|
|
|
await cache.save_result(test, key="test")
|
|
result = await cache.save_result(test, key="test")
|
|
assert result == called == 1
|
|
|
|
|
|
def test_cached_older_than():
|
|
cached = Cached(value="value", expiry=time() - 5)
|
|
|
|
assert cached.older_than(1) is True
|
|
assert cached.older_than(10) is False
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cache_value_returns_cached_metadata(cache):
|
|
cache.set(key, value, expiry=1)
|
|
|
|
cached = cache.value(key)
|
|
|
|
assert cached is not None
|
|
assert cached.value == value
|
|
assert cached.expiry > time()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cache_pop_expired_returns_default(cache):
|
|
cache.set(key, value, expiry=0.01)
|
|
await asyncio.sleep(0.02)
|
|
|
|
assert cache.pop(key, default="fallback") == "fallback"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cache_coro_stale_returns_immediately(cache):
|
|
"""Stale entry is served immediately; background refresh updates the value."""
|
|
calls = 0
|
|
|
|
async def test():
|
|
nonlocal calls
|
|
calls += 1
|
|
return calls
|
|
|
|
# cold start
|
|
result = await cache.save_result(test, key="test", expiry=0.01)
|
|
assert result == 1
|
|
|
|
# let the entry expire
|
|
await asyncio.sleep(0.02)
|
|
|
|
# stale-while-revalidate: returns old value immediately
|
|
result = await cache.save_result(test, key="test", expiry=0.5)
|
|
assert result == 1 # stale value returned, not the new one
|
|
|
|
# allow background refresh to complete
|
|
await asyncio.sleep(0.05)
|
|
assert calls == 2
|
|
# now the cache has the fresh value
|
|
result = await cache.save_result(test, key="test", expiry=0.5)
|
|
assert result == 2
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cache_coro_no_stampede(cache):
|
|
"""Multiple concurrent requests on a stale entry spawn only one refresh."""
|
|
calls = 0
|
|
|
|
async def slow_fetch():
|
|
nonlocal calls
|
|
calls += 1
|
|
await asyncio.sleep(0.05)
|
|
return calls
|
|
|
|
await cache.save_result(slow_fetch, key="test", expiry=0.01)
|
|
await asyncio.sleep(0.02)
|
|
|
|
# fire multiple concurrent requests while stale
|
|
results = await asyncio.gather(
|
|
cache.save_result(slow_fetch, key="test", expiry=0.5),
|
|
cache.save_result(slow_fetch, key="test", expiry=0.5),
|
|
cache.save_result(slow_fetch, key="test", expiry=0.5),
|
|
)
|
|
|
|
await asyncio.sleep(0.1) # let the single background task finish
|
|
assert all(r == 1 for r in results) # all got stale value
|
|
assert calls == 2 # cold start + exactly one background refresh
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invalidate_forever_logs_and_recovers_from_errors(
|
|
settings: Settings, mocker: MockerFixture
|
|
):
|
|
test_cache = Cache(interval=0)
|
|
logger_error = mocker.patch("lnbits.utils.cache.logger.error")
|
|
original_running = settings.lnbits_running
|
|
calls = 0
|
|
|
|
async def fake_sleep(_interval):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
raise RuntimeError("boom")
|
|
settings.lnbits_running = False
|
|
|
|
try:
|
|
settings.lnbits_running = True
|
|
mocker.patch("lnbits.utils.cache.asyncio.sleep", side_effect=fake_sleep)
|
|
await test_cache.invalidate_forever()
|
|
finally:
|
|
settings.lnbits_running = original_running
|
|
|
|
logger_error.assert_called_once_with("Error invalidating cache")
|