[BugFix] openbb-cboe: Fix cache lock so that concurrent callers in separate processes don't hang (#7796)

* fix cboe's cache lock so that concurrent callers in separate processes don't hang

* fix test coverage and ty check

* more test failure

* more test failure

* more Windows Python 3.10 test failure

* Move lint path filter into a job so required checks always report

lint-openbb-platform.yml used `on.pull_request.paths` to skip PRs that only
touch api/extensions/providers/obbject_extensions. A workflow skipped by a
trigger filter never reports its checks, so the required "General Code
Linting" and "Markdown Linting" checks stayed pending forever on those PRs.

Detect relevant changes in a `changes` job instead and gate the lint jobs with
a job-level `if:`. Skipped jobs report success, so required checks resolve.
The gate fails closed: if detection fails, the lint jobs still run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: dlee-bq <325002271+dlee-bq@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
dlee-bq
2026-10-16 17:50:40 +00:00
committed by GitHub
parent 7c9d4221bd
commit 7f49354884
9 changed files with 1522 additions and 322 deletions

View File

@@ -6,15 +6,6 @@ env:
on:
pull_request:
types: [opened, synchronize, edited]
paths:
- 'openbb_platform/**'
- '!openbb_platform/api/**'
- '!openbb_platform/extensions/**'
- '!openbb_platform/providers/**'
- '!openbb_platform/obbject_extensions/**'
- '.github/workflows/lint-openbb-platform.yml'
- '.github/codeql/**'
- '.github/workflows/codeql.yml'
merge_group:
types: [checks_requested]
@@ -23,8 +14,46 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
name: Detect Changes
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
platform: ${{ steps.filter.outputs.platform }}
steps:
- name: Check for shared platform changes
id: filter
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Path filtering lives here instead of `on.pull_request.paths`: a workflow
# skipped by a trigger filter never reports its checks, which leaves the
# required "General Code Linting" and "Markdown Linting" checks pending
# forever. Jobs skipped by a job-level `if:` report success instead.
if [ "$EVENT_NAME" != "pull_request" ]; then
echo "platform=true" >> "$GITHUB_OUTPUT"
exit 0
fi
gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[].filename' > changed_files.txt
if awk '
/^\.github\/(workflows\/(lint-openbb-platform|codeql)\.yml$|codeql\/)/ { found = 1 }
/^openbb_platform\// && !/^openbb_platform\/(api|extensions|providers|obbject_extensions)\// { found = 1 }
END { exit !found }
' changed_files.txt; then
echo "platform=true" >> "$GITHUB_OUTPUT"
else
echo "platform=false" >> "$GITHUB_OUTPUT"
fi
code-linting:
name: General Code Linting
needs: changes
# Fail closed: run unless change detection explicitly found nothing relevant.
if: ${{ !cancelled() && needs.changes.outputs.platform != 'false' }}
runs-on: ubuntu-latest
env:
UV_CACHE_DIR: ${{ github.workspace }}/.uv-cache
@@ -122,6 +151,8 @@ jobs:
markdown-link-check:
name: Markdown Linting
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.platform != 'false' }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -136,6 +167,8 @@ jobs:
json-yaml-validate:
name: JSON Check
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.platform != 'false' }}
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -3,6 +3,10 @@
from __future__ import annotations
import logging
import os
import sqlite3
import threading
from contextlib import asynccontextmanager
from datetime import (
date as dateType,
datetime,
@@ -149,10 +153,17 @@ async def response_callback(response, _) -> Any:
return await response.read()
_cache: Any = None
# Every cached request shares one SQLite connection, which lives on an event loop
# of its own (see ``_get_cache_loop``). ``_cache_lock`` guards the state below that
# threads outside that loop also touch; ``_stores`` and ``_swept`` belong to it, and
# ``_cache_guard`` lets one thing at a time open, use or close the connection.
_cache_lock = threading.Lock()
_cache_loop: Any = None
_swept = False
_cache_guard: Any = None
_cache_users = 0
_stores: Any = None
_swept = False
_inherited: list = []
def cache_path() -> str:
@@ -175,6 +186,30 @@ async def _oldest(backend: Any, count: int) -> set:
return keys
async def _pragma(connection: Any, statement: str) -> Any:
"""Run a PRAGMA and return its row, leaving no statement open behind it."""
cursor = await connection.execute(statement)
try:
return await cursor.fetchone()
finally:
await cursor.close()
async def _size(backend: Any) -> int:
"""Return how big the cache file is, counting what is still in its log.
The file on disk cannot say. After a ``VACUUM`` in write-ahead mode the new
pages wait in the log until it is checkpointed, and the file keeps its old
size until then.
"""
async with backend.responses.get_connection() as connection:
(pages,) = await _pragma(connection, "PRAGMA page_count")
(page_size,) = await _pragma(connection, "PRAGMA page_size")
return pages * page_size
async def _sweep(backend: Any) -> None:
"""Drop what has expired, then the oldest of what is left if still too big.
@@ -184,18 +219,17 @@ async def _sweep(backend: Any) -> None:
of the file that overshoots it, which is one rewrite of the file rather than
one per entry.
"""
from pathlib import Path
await backend.delete_expired_responses()
await _vacuum(backend)
stored = Path(f"{cache_path()}.sqlite")
for _ in range(CACHE_SWEEP_PASSES):
if not stored.exists() or stored.stat().st_size <= CACHE_MAX_BYTES:
size = await _size(backend)
if size <= CACHE_MAX_BYTES:
return
held = await backend.responses.size()
overshoot = 1 - CACHE_MAX_BYTES / stored.stat().st_size
overshoot = 1 - CACHE_MAX_BYTES / size
dropped = await _oldest(backend, max(1, round(held * overshoot)))
if not dropped:
@@ -206,74 +240,297 @@ async def _sweep(backend: Any) -> None:
async def _vacuum(backend: Any) -> None:
"""Give back the pages the deleted responses were held in."""
"""Give back the pages the deleted responses were held in, if there are any.
A ``VACUUM`` rewrites the whole file while holding it, and every process
sweeps as it starts, so one that has nothing to give back must not run it.
"""
async with backend.responses.get_connection(commit=True) as connection:
(unused,) = await _pragma(connection, "PRAGMA freelist_count")
if not unused:
return
await connection.execute("VACUUM")
# In write-ahead mode the vacuumed file reaches the disk, and gives its
# space back, only when the log is checkpointed.
await _pragma(connection, "PRAGMA wal_checkpoint(TRUNCATE)")
async def close_cache_backend(backend: Any) -> None:
"""Close the shared cache and forget it, so the next caller builds a fresh one.
async def _use_wal(stores: Any) -> None:
"""Put the cache file in write-ahead logging, where readers and a writer coexist.
``SQLiteBackend.close()`` clears the connection object the backend was
constructed with and never rebuilds it, so a closed backend is permanently
unusable - every later request through it dies on ``NoneType has no
attribute '_connection'``. Dropping the module reference is what makes the
close recoverable.
Otherwise a process writing to the file makes every other process wait to
read it, and a process reading it makes the writer wait to commit; one that
holds the file for longer than SQLite will wait makes the request fail with
``database is locked``. The mode belongs to the file, so it holds for every
process that opens it from now on. A file that cannot be switched - another
process is part way through a write, or the file system cannot keep the shared
memory the mode needs - keeps working in its default mode.
Parameters
----------
backend : Any
The backend to close; ignored if it is no longer the shared one.
stores : SQLiteBackend
The stores whose file is switched.
"""
global _cache, _cache_loop # noqa: PLW0603
try:
async with stores.responses.get_connection() as connection:
row = await _pragma(connection, "PRAGMA journal_mode=WAL")
except Exception: # noqa: BLE001
_logger.exception("Could not put the Cboe response cache in WAL mode")
if _cache is backend:
_cache = None
_cache_loop = None
return
if not row or str(row[0]).lower() != "wal":
_logger.warning("The Cboe response cache is staying in %s mode", row)
class _CacheConnection(sqlite3.Connection):
"""A connection set up for a cache that many processes share.
It commits each statement as it runs. By default a write opens a transaction,
takes the file's write lock, and only gives it up when a second call, made
after the event loop has had its turn, commits it. Other processes wait out
that gap, however long a busy machine makes it, and a write cancelled inside
it leaves the lock held until something else commits.
It does not wait for the disk after each commit either (``synchronous`` is
``NORMAL``). A commit that waits holds the write lock for as long as the disk
takes, which on a slow one is long enough for every other process to give up
on the lock; what the wait buys is surviving a power cut, and a response that
is lost is fetched again.
Both have to be set by the thread the connection lives on, and the library
drops ``isolation_level=None`` along with every other ``None`` argument, so
they are set here, where the library hands a connection ``factory`` its
arguments.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.isolation_level = None
self.execute("PRAGMA synchronous = NORMAL")
def _new_backend() -> Any:
"""Build the SQLite stores of the response cache."""
from aiohttp_client_cache import SQLiteBackend
return SQLiteBackend(cache_path(), autoclose=False, factory=_CacheConnection)
async def _sweep_file() -> None:
"""Sweep the cache file on a connection of its own, closed when it is done.
The sweep can stop part way - on a row that cannot be read back, say - with a
read still open. It runs before the requests' connection exists, and on a
connection that is closed whatever happens, so nothing of it is left holding
the file once it is over.
"""
backend = _new_backend()
try:
await backend.close()
await _sweep(backend)
except Exception: # noqa: BLE001
_logger.exception("Closing the Cboe response cache failed")
_logger.exception("Sweeping the Cboe response cache failed")
finally:
try:
await backend.close()
except Exception: # noqa: BLE001
_logger.exception("Closing the Cboe sweep connection failed")
async def cache_backend() -> Any:
"""Return the shared cache for the running event loop, swept on first use.
def _get_cache_loop() -> Any:
"""Return the event loop the cache's connection lives on, starting it if needed.
An aiosqlite connection, and the lock beside it, only work on the loop that
first used them. Requests run on whatever loop their caller has - core's
``run_async`` gives every synchronous call a thread and a loop of its own -
so a connection per loop would be one writer per loop on a single file, and
one connection shared between loops would fail with ``bound to a different
event loop`` or wait on a wake-up that never reaches its loop. One loop, on a
thread of its own, owns the single connection, and requests reach it from
wherever they run.
Returns
-------
AbstractEventLoop
The loop, running on a daemon thread, that every cache operation uses.
"""
global _cache_loop, _cache_guard # noqa: PLW0603
from asyncio import Lock, new_event_loop
with _cache_lock:
if _cache_loop is None:
loop = new_event_loop()
threading.Thread(
target=loop.run_forever, name="cboe-cache", daemon=True
).start()
_cache_guard = Lock()
_cache_loop = loop
return _cache_loop
async def _on_cache_loop(operation: Any) -> Any:
"""Run a coroutine on the cache loop and wait for it from the running one."""
from asyncio import run_coroutine_threadsafe, wrap_future
return await wrap_future(run_coroutine_threadsafe(operation, _get_cache_loop()))
async def _store_call(table: str, method: str, *args: Any) -> Any:
"""Run a method of one of the cache's stores where its connection is.
One at a time. A read leaves its statement open until it is collected, so a
connection that starts a write while another request's read is still open
holds the file for reading as it asks to write. Another process committing at
that moment makes SQLite refuse the write at once, as ``database is locked``,
where it would otherwise have waited. A connection that finishes each
operation before it starts the next never asks for both.
Parameters
----------
table : str
``responses`` or ``redirects``.
method : str
The store's method.
*args : Any
The method's arguments.
Returns
-------
Any
The ``SQLiteBackend`` every cached request on the running loop shares.
What the method returns; a method that yields returns the list it yielded.
"""
global _cache, _cache_loop, _swept, _cache_users # noqa: PLW0603
from asyncio import get_running_loop
async def call() -> Any:
async with _cache_guard:
result = getattr(getattr(_stores, table), method)(*args)
from aiohttp_client_cache import SQLiteBackend
if hasattr(result, "__aiter__"):
return [item async for item in result]
loop = get_running_loop()
return await result
if _cache is None or (_cache_loop is not loop and _cache_users == 0):
_cache = SQLiteBackend(
cache_path(),
return await _on_cache_loop(call())
async def _open() -> None:
"""Open the stores if they are not open, sweeping the file first the first time.
Runs on the cache loop. The sweep finishes before the stores exist, so no
request is ever in the middle of the file while it is vacuumed.
"""
global _stores, _swept # noqa: PLW0603
async with _cache_guard:
if _stores is not None:
return
if not _swept:
await _sweep_file()
_swept = True
_stores = _new_backend()
await _use_wal(_stores)
async def _close() -> None:
"""Close the stores if no request is using them any more.
``SQLiteBackend.close()`` clears the connection object the backend was built
with and never rebuilds it, so a closed backend cannot be used again. The next
request opens a fresh one.
"""
global _stores # noqa: PLW0603
async with _cache_guard:
with _cache_lock:
idle = _cache_users == 0
if _stores is None or not idle:
return
stores, _stores = _stores, None
try:
await stores.close()
except Exception: # noqa: BLE001
_logger.exception("Closing the Cboe response cache failed")
@asynccontextmanager
async def cache_backend():
"""Provide the response cache for the length of one request.
Every request, on every loop and thread, is backed by the same connection.
It is opened for the first request to need it and closed after the last has
finished, so an idle process holds no connection open.
Yields
------
SharedCache
The ``aiohttp_client_cache`` backend to build a ``CachedSession`` on.
"""
global _cache_users # noqa: PLW0603
from asyncio import run_coroutine_threadsafe, shield, wrap_future
from openbb_cboe.utils.shared_cache import SharedCache
with _cache_lock:
_cache_users += 1
try:
await _on_cache_loop(_open())
yield SharedCache(
_store_call,
cache_name=CACHE_NAME,
expire_after=CACHE_TTL["*"],
urls_expire_after=CACHE_TTL,
autoclose=False,
)
_cache_loop = loop
if not _swept:
_swept = True
_cache_users += 1
try:
await _sweep(_cache)
except Exception: # noqa: BLE001
_logger.exception("Sweeping the Cboe response cache failed")
finally:
finally:
with _cache_lock:
_cache_users -= 1
return _cache
# Handed to the cache loop here and now, not by a task that could be
# cancelled, or whose loop could end, before it ever started: a close
# that is lost leaves the connection, and its thread, open for good.
closing = run_coroutine_threadsafe(_close(), _get_cache_loop())
await shield(wrap_future(closing))
def _forget_cache_loop() -> None:
"""Start over in a forked child, whose cache loop's thread did not survive.
The child would otherwise wait for ever on a loop that is not running. The
parent's connection is kept, never closed or collected, because closing an
SQLite connection in the process it was not opened in can undo the parent's
work on the file.
"""
global _cache_lock, _cache_loop, _cache_guard, _cache_users, _stores # noqa: PLW0603
if _stores is not None:
_inherited.append(_stores)
_cache_lock = threading.Lock()
_cache_loop = None
_cache_guard = None
_cache_users = 0
_stores = None
def _forget_after_fork() -> None:
"""Have a forked child start over, on the platforms that can fork."""
if hasattr(os, "register_at_fork"):
os.register_at_fork(after_in_child=_forget_cache_loop)
_forget_after_fork()
async def get_cboe_data(url: str, use_cache: bool = True, **kwargs) -> Any:
@@ -292,26 +549,15 @@ async def get_cboe_data(url: str, use_cache: bool = True, **kwargs) -> Any:
Any
The deserialized response.
"""
global _cache_users # noqa: PLW0603
from aiohttp_client_cache.session import CachedSession
if use_cache is not True:
return await amake_request(url, response_callback=response_callback, **kwargs)
backend = await cache_backend()
_cache_users += 1
async with cache_backend() as backend, CachedSession(cache=backend) as session:
response = await session.get(url, timeout=10, **kwargs)
try:
async with CachedSession(cache=backend) as session:
response = await session.get(url, timeout=10, **kwargs)
return await response_callback(response, None)
finally:
_cache_users -= 1
if _cache_users == 0:
await close_cache_backend(backend)
return await response_callback(response, None)
async def get_company_directory(use_cache: bool = True, **kwargs) -> DataFrame:

View File

@@ -146,7 +146,7 @@ def create_surface(
"open_interest",
"volume",
]
df = df[[c for c in dict.fromkeys(columns) if c in df.columns]]
df = df[[c for c in dict.fromkeys(columns) if c in df.columns]].copy()
metric_title = METRIC_TITLES.get(metric, metric.replace("_", " ").title())
label = f"{symbol} {OPTION_TYPE_LABELS[option_type]} {metric_title} Surface"

View File

@@ -0,0 +1,103 @@
"""Response cache whose storage is reached through another event loop.
An aiosqlite connection, and the lock that guards it, only work on the event
loop that first used them. Cboe requests run on whichever loop their caller
has - core's ``run_async`` gives every synchronous call a thread and a loop of
its own - so the one connection they all share has to live on a loop of its
own, and each request reaches it from wherever it is running. These classes are
that reach: they implement the storage interface ``aiohttp_client_cache`` asks
of a custom backend and leave the work to a ``call`` function.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any
from aiohttp_client_cache import CacheBackend
from aiohttp_client_cache.backends import BaseCache
StoreCall = Callable[..., Awaitable[Any]]
"""``call(table, method, *args)`` runs a store's method where its connection is.
A method that yields, like ``keys``, comes back as the list of what it yielded.
"""
class SharedStore(BaseCache):
"""One table of the shared cache, usable from any event loop.
Parameters
----------
table : str
The name of the backend's store this stands for, ``responses`` or
``redirects``.
call : StoreCall
Runs a method of that store where the connection is.
"""
def __init__(self, table: str, call: StoreCall) -> None:
super().__init__()
self._table = table
self._call = call
async def contains(self, key: str) -> bool:
"""Check if a key is stored in the cache."""
return await self._call(self._table, "contains", key)
async def clear(self) -> None:
"""Delete all items from the cache."""
await self._call(self._table, "clear")
async def close(self) -> None:
"""Leave the connection open: other requests are still using it."""
async def delete(self, key: str) -> None:
"""Delete an item from the cache."""
await self._call(self._table, "delete", key)
async def bulk_delete(self, keys: set) -> None:
"""Delete items from the cache."""
await self._call(self._table, "bulk_delete", keys)
async def keys(self) -> AsyncIterator[str]: # type: ignore[override]
"""Yield all keys stored in the cache."""
for key in await self._call(self._table, "keys"):
yield key
async def read(self, key: str) -> Any:
"""Read an item from the cache; ``None`` if it is missing."""
return await self._call(self._table, "read", key)
async def size(self) -> int:
"""Get the number of items in the cache."""
return await self._call(self._table, "size")
async def values(self) -> AsyncIterator[Any]: # type: ignore[override]
"""Yield all values stored in the cache."""
for value in await self._call(self._table, "values"):
yield value
async def write(self, key: str, item: Any) -> None:
"""Write an item to the cache."""
await self._call(self._table, "write", key, item)
class SharedCache(CacheBackend):
"""A response cache that keeps what it stores through ``call``.
Expiry, keys and redirects are decided here, on the caller's loop, as they
are for any backend; only reading and writing leave it.
Parameters
----------
call : StoreCall
Runs a store's method where the connection is.
**kwargs
The settings of ``aiohttp_client_cache.CacheBackend``.
"""
def __init__(self, call: StoreCall, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.responses = SharedStore("responses", call)
self.redirects = SharedStore("redirects", call)

View File

@@ -79,6 +79,18 @@ exclude = ["tests", "integration"]
[tool.ty.rules]
unresolved-import = "warn"
# The models extend the platform's standard models by overriding their fields:
# a query parameter narrowed to a ``Literal`` (core derives the parameter's choices
# from it), or a field the provider may leave empty. ty treats both as unsound
# overrides of a mutable attribute. The check is off for the model modules, where
# that is how a provider is written, and stays on everywhere else.
[[tool.ty.overrides]]
include = ["openbb_cboe/models/**"]
[tool.ty.overrides.rules]
invalid-attribute-override = "ignore"
invalid-mutable-override = "ignore"
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@overload"]

View File

@@ -0,0 +1,73 @@
"""One OS process making cached requests, for the tests that share a cache file.
Run as ``cache_worker.py URL CACHE_FILE ROUNDS REQUESTS_PER_ROUND THREADS``. Each
thread runs bursts of concurrent requests on an event loop of its own, over a few
dozen URLs the other processes ask for too, and the last line printed is a JSON
summary of how many succeeded and what the failures were.
"""
import asyncio
import collections
import json
import logging
import sys
import threading
import traceback
from pathlib import Path
from openbb_cboe.utils import helpers
URL, CACHE_FILE, ROUNDS, PER_ROUND, THREADS = sys.argv[1:6]
URLS = 40
# Requests in flight per event loop. A test server can only queue so many
# connections at once (128 on macOS); this keeps the workers' total under it, so
# what the test measures is the cache and not the server's accept queue.
IN_FLIGHT = 2
logging.disable(logging.CRITICAL)
helpers.cache_path = lambda: CACHE_FILE
succeeded = 0
failures: collections.Counter = collections.Counter()
async def request(number, turn):
"""Request one URL, recording how it went."""
global succeeded # noqa: PLW0603
try:
async with turn:
await asyncio.wait_for(
helpers.get_cboe_data(f"{URL}/{number % URLS}.json"), timeout=30
)
succeeded += 1
except Exception as error: # noqa: BLE001
frames = traceback.extract_tb(error.__traceback__)[-4:]
where = " <- ".join(
f"{Path(frame.filename).name}:{frame.lineno} {frame.name}"
for frame in reversed(frames)
)
failures[f"{type(error).__name__}: {error} (at {where})"] += 1
def work(thread):
"""Run the bursts of one thread, each on a new event loop."""
async def burst(first):
turn = asyncio.Semaphore(IN_FLIGHT)
await asyncio.gather(*(request(first + n, turn) for n in range(int(PER_ROUND))))
for round_number in range(int(ROUNDS)):
asyncio.run(burst(round_number * int(PER_ROUND) + thread * 7))
threads = [threading.Thread(target=work, args=(n,)) for n in range(int(THREADS))]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
sys.stdout.write(json.dumps({"succeeded": succeeded, "failures": failures}) + "\n")

File diff suppressed because it is too large Load Diff

View File

@@ -189,6 +189,10 @@ class TestStats:
assert output.chart.content["data"]
# pandas warns that way when a frame that is a view of another is written to. The
# class it uses is not in every version, so it is matched by what it says, and the
# message starts on a new line, hence the flag that lets ``.`` match one.
@pytest.mark.filterwarnings("error:(?s).*trying to be set on a copy of a slice")
class TestSurface:
"""3-D surface builder."""

View File

@@ -0,0 +1,108 @@
"""Tests for openbb_cboe.utils.shared_cache."""
import asyncio
from unittest.mock import AsyncMock
import pytest
from openbb_cboe.utils.shared_cache import SharedCache, SharedStore
async def _gather(iterable):
"""Collect everything an async iterable yields."""
return [item async for item in iterable]
class TestSharedStore:
"""A store hands every operation to ``call``, whichever loop it is used on."""
@pytest.mark.parametrize(
("method", "args", "returned"),
[
("contains", ("key",), True),
("read", ("key",), "item"),
("size", (), 7),
("write", ("key", "item"), None),
("delete", ("key",), None),
("bulk_delete", ({"key"},), None),
("clear", (), None),
],
)
def test_an_operation_is_run_where_the_connection_is(self, method, args, returned):
"""The table, the method and its arguments go over, and the result comes back."""
call = AsyncMock(return_value=returned)
store = SharedStore("responses", call)
result = asyncio.run(getattr(store, method)(*args))
assert result == returned
call.assert_awaited_once_with("responses", method, *args)
@pytest.mark.parametrize("method", ["keys", "values"])
def test_what_is_yielded_is_yielded_here(self, method):
"""Keys and values arrive gathered, and are yielded on the caller's loop."""
call = AsyncMock(return_value=["a", "b"])
store = SharedStore("redirects", call)
assert asyncio.run(_gather(getattr(store, method)())) == ["a", "b"]
call.assert_awaited_once_with("redirects", method)
def test_closing_leaves_the_connection_to_the_cache(self):
"""Other requests are still using the connection, so a session may not close it."""
call = AsyncMock()
asyncio.run(SharedStore("responses", call).close())
call.assert_not_awaited()
def test_pop_reads_then_deletes(self):
"""The base class's ``pop`` works through the same two operations."""
call = AsyncMock(side_effect=lambda table, method, *args: "item")
assert asyncio.run(SharedStore("responses", call).pop("key")) == "item"
assert [step.args[1] for step in call.await_args_list] == ["read", "delete"]
def test_an_error_where_the_connection_is_reaches_the_caller(self):
"""A failed read is the failure of the request that made it."""
call = AsyncMock(side_effect=OSError("disk"))
with pytest.raises(OSError):
asyncio.run(SharedStore("responses", call).read("key"))
class TestSharedCache:
"""The backend decides expiry and keys itself, and stores through ``call``."""
def test_its_tables_are_the_responses_and_the_redirects(self):
"""Each table of the backend is a store of the same name."""
call = AsyncMock()
cache = SharedCache(call)
asyncio.run(cache.responses.write("key", "item"))
asyncio.run(cache.redirects.write("alias", "key"))
assert [step.args[:2] for step in call.await_args_list] == [
("responses", "write"),
("redirects", "write"),
]
def test_it_keeps_the_settings_it_is_given(self):
"""Expiry is applied on the caller's loop, from the backend's own settings."""
cache = SharedCache(
AsyncMock(),
expire_after=60,
urls_expire_after={"*/book/*": 5},
autoclose=False,
)
assert cache.expire_after == 60
assert cache.urls_expire_after == {"*/book/*": 5}
assert cache.autoclose is False
def test_closing_it_does_not_close_the_shared_connection(self):
"""Closing the backend is not closing the connection other requests use."""
call = AsyncMock()
asyncio.run(SharedCache(call).close())
call.assert_not_awaited()