mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 04:31:13 +00:00
[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:
51
.github/workflows/lint-openbb-platform.yml
vendored
51
.github/workflows/lint-openbb-platform.yml
vendored
@@ -6,15 +6,6 @@ env:
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, edited]
|
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:
|
merge_group:
|
||||||
types: [checks_requested]
|
types: [checks_requested]
|
||||||
|
|
||||||
@@ -23,8 +14,46 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
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:
|
code-linting:
|
||||||
name: General 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
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
UV_CACHE_DIR: ${{ github.workspace }}/.uv-cache
|
UV_CACHE_DIR: ${{ github.workspace }}/.uv-cache
|
||||||
@@ -122,6 +151,8 @@ jobs:
|
|||||||
|
|
||||||
markdown-link-check:
|
markdown-link-check:
|
||||||
name: Markdown Linting
|
name: Markdown Linting
|
||||||
|
needs: changes
|
||||||
|
if: ${{ !cancelled() && needs.changes.outputs.platform != 'false' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -136,6 +167,8 @@ jobs:
|
|||||||
|
|
||||||
json-yaml-validate:
|
json-yaml-validate:
|
||||||
name: JSON Check
|
name: JSON Check
|
||||||
|
needs: changes
|
||||||
|
if: ${{ !cancelled() && needs.changes.outputs.platform != 'false' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from datetime import (
|
from datetime import (
|
||||||
date as dateType,
|
date as dateType,
|
||||||
datetime,
|
datetime,
|
||||||
@@ -149,10 +153,17 @@ async def response_callback(response, _) -> Any:
|
|||||||
return await response.read()
|
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
|
_cache_loop: Any = None
|
||||||
_swept = False
|
_cache_guard: Any = None
|
||||||
_cache_users = 0
|
_cache_users = 0
|
||||||
|
_stores: Any = None
|
||||||
|
_swept = False
|
||||||
|
_inherited: list = []
|
||||||
|
|
||||||
|
|
||||||
def cache_path() -> str:
|
def cache_path() -> str:
|
||||||
@@ -175,6 +186,30 @@ async def _oldest(backend: Any, count: int) -> set:
|
|||||||
return keys
|
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:
|
async def _sweep(backend: Any) -> None:
|
||||||
"""Drop what has expired, then the oldest of what is left if still too big.
|
"""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
|
of the file that overshoots it, which is one rewrite of the file rather than
|
||||||
one per entry.
|
one per entry.
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
await backend.delete_expired_responses()
|
await backend.delete_expired_responses()
|
||||||
await _vacuum(backend)
|
await _vacuum(backend)
|
||||||
stored = Path(f"{cache_path()}.sqlite")
|
|
||||||
|
|
||||||
for _ in range(CACHE_SWEEP_PASSES):
|
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
|
return
|
||||||
|
|
||||||
held = await backend.responses.size()
|
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)))
|
dropped = await _oldest(backend, max(1, round(held * overshoot)))
|
||||||
|
|
||||||
if not dropped:
|
if not dropped:
|
||||||
@@ -206,74 +240,297 @@ async def _sweep(backend: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _vacuum(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:
|
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")
|
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:
|
async def _use_wal(stores: Any) -> None:
|
||||||
"""Close the shared cache and forget it, so the next caller builds a fresh one.
|
"""Put the cache file in write-ahead logging, where readers and a writer coexist.
|
||||||
|
|
||||||
``SQLiteBackend.close()`` clears the connection object the backend was
|
Otherwise a process writing to the file makes every other process wait to
|
||||||
constructed with and never rebuilds it, so a closed backend is permanently
|
read it, and a process reading it makes the writer wait to commit; one that
|
||||||
unusable - every later request through it dies on ``NoneType has no
|
holds the file for longer than SQLite will wait makes the request fail with
|
||||||
attribute '_connection'``. Dropping the module reference is what makes the
|
``database is locked``. The mode belongs to the file, so it holds for every
|
||||||
close recoverable.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
backend : Any
|
stores : SQLiteBackend
|
||||||
The backend to close; ignored if it is no longer the shared one.
|
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:
|
return
|
||||||
_cache = None
|
|
||||||
_cache_loop = None
|
|
||||||
|
|
||||||
|
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 _sweep(backend)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_logger.exception("Sweeping the Cboe response cache failed")
|
||||||
|
finally:
|
||||||
try:
|
try:
|
||||||
await backend.close()
|
await backend.close()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_logger.exception("Closing the Cboe response cache failed")
|
_logger.exception("Closing the Cboe sweep connection failed")
|
||||||
|
|
||||||
|
|
||||||
async def cache_backend() -> Any:
|
def _get_cache_loop() -> Any:
|
||||||
"""Return the shared cache for the running event loop, swept on first use.
|
"""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
|
Returns
|
||||||
-------
|
-------
|
||||||
Any
|
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):
|
return await _on_cache_loop(call())
|
||||||
_cache = SQLiteBackend(
|
|
||||||
cache_path(),
|
|
||||||
|
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["*"],
|
expire_after=CACHE_TTL["*"],
|
||||||
urls_expire_after=CACHE_TTL,
|
urls_expire_after=CACHE_TTL,
|
||||||
autoclose=False,
|
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
|
_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:
|
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
|
Any
|
||||||
The deserialized response.
|
The deserialized response.
|
||||||
"""
|
"""
|
||||||
global _cache_users # noqa: PLW0603
|
|
||||||
|
|
||||||
from aiohttp_client_cache.session import CachedSession
|
from aiohttp_client_cache.session import CachedSession
|
||||||
|
|
||||||
if use_cache is not True:
|
if use_cache is not True:
|
||||||
return await amake_request(url, response_callback=response_callback, **kwargs)
|
return await amake_request(url, response_callback=response_callback, **kwargs)
|
||||||
|
|
||||||
backend = await cache_backend()
|
async with cache_backend() as backend, CachedSession(cache=backend) as session:
|
||||||
_cache_users += 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with CachedSession(cache=backend) as session:
|
|
||||||
response = await session.get(url, timeout=10, **kwargs)
|
response = await session.get(url, timeout=10, **kwargs)
|
||||||
|
|
||||||
return await response_callback(response, None)
|
return await response_callback(response, None)
|
||||||
finally:
|
|
||||||
_cache_users -= 1
|
|
||||||
|
|
||||||
if _cache_users == 0:
|
|
||||||
await close_cache_backend(backend)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_company_directory(use_cache: bool = True, **kwargs) -> DataFrame:
|
async def get_company_directory(use_cache: bool = True, **kwargs) -> DataFrame:
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ def create_surface(
|
|||||||
"open_interest",
|
"open_interest",
|
||||||
"volume",
|
"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())
|
metric_title = METRIC_TITLES.get(metric, metric.replace("_", " ").title())
|
||||||
label = f"{symbol} {OPTION_TYPE_LABELS[option_type]} {metric_title} Surface"
|
label = f"{symbol} {OPTION_TYPE_LABELS[option_type]} {metric_title} Surface"
|
||||||
|
|
||||||
|
|||||||
103
openbb_platform/providers/cboe/openbb_cboe/utils/shared_cache.py
Normal file
103
openbb_platform/providers/cboe/openbb_cboe/utils/shared_cache.py
Normal 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)
|
||||||
12
openbb_platform/providers/cboe/pyproject.toml
vendored
12
openbb_platform/providers/cboe/pyproject.toml
vendored
@@ -79,6 +79,18 @@ exclude = ["tests", "integration"]
|
|||||||
[tool.ty.rules]
|
[tool.ty.rules]
|
||||||
unresolved-import = "warn"
|
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]
|
[tool.coverage.report]
|
||||||
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@overload"]
|
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@overload"]
|
||||||
|
|
||||||
|
|||||||
73
openbb_platform/providers/cboe/tests/cache_worker.py
Normal file
73
openbb_platform/providers/cboe/tests/cache_worker.py
Normal 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
@@ -189,6 +189,10 @@ class TestStats:
|
|||||||
assert output.chart.content["data"]
|
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:
|
class TestSurface:
|
||||||
"""3-D surface builder."""
|
"""3-D surface builder."""
|
||||||
|
|
||||||
|
|||||||
108
openbb_platform/providers/cboe/tests/test_shared_cache.py
Normal file
108
openbb_platform/providers/cboe/tests/test_shared_cache.py
Normal 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()
|
||||||
Reference in New Issue
Block a user