Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 62 additions & 13 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,63 @@ Project-local and user configs merge per file: `./.qracer/providers.toml` define

## Provider Plugin System

> **구현 예정** — 현재는 `provider_catalog.py` 기반 하드코딩 방식으로 동작합니다.

Built-in adapters and external plugins share the same `ProviderPlugin` protocol. Lifecycle methods (`initialize`, `health_check`, `shutdown`) require DataRegistry updates — tracked separately from current implementation.
Built-in adapters and external plugins share the same capability protocols
(`PriceProvider`, `NewsProvider`, …) and are registered from the same
`providers.toml` config. Adapters may additionally implement the optional
`LifecycleProvider` protocol to opt into per-provider `initialize`,
`health_check`, and `shutdown` hooks — invoked by `_build_registries()` at
startup and by `qracer serve` on shutdown.

```python
class ProviderPlugin(Protocol):
name: str
capabilities: list[Capability]
tier: Tier # hot | warm | cold

async def initialize(self, config: ProviderConfig) -> None: ...
# qracer/provider_lifecycle.py
@runtime_checkable
class LifecycleProvider(Protocol):
async def initialize(self) -> None: ...
async def health_check(self) -> bool: ...
async def shutdown(self) -> None: ...
```

All three methods are optional — adapters without them are treated as
always-healthy and require no teardown. A provider that raises in
`initialize()` or returns `False` from `health_check()` is excluded from the
registry with a warning instead of crashing the process.

### Example third-party adapter

```python
# qracer_polygon/adapter.py
class PolygonAdapter:
"""External data provider with graceful lifecycle management."""

def __init__(self, api_key: str | None = None) -> None:
self._api_key = api_key
self._session: httpx.AsyncClient | None = None

async def initialize(self) -> None:
self._session = httpx.AsyncClient(timeout=10.0)

async def health_check(self) -> bool:
if self._session is None:
return False
resp = await self._session.get("https://api.polygon.io/v1/status")
return resp.status_code == 200

async def shutdown(self) -> None:
if self._session is not None:
await self._session.aclose()

async def get_price(self, ticker: str) -> float:
...
```

Register it via the entry-point group:

```toml
# External package pyproject.toml
[project.entry-points."qracer.data_providers"]
polygon = "qracer_polygon.adapter:PolygonAdapter"
```

### Built-in vs Plugin

| Type | Location | Install |
Expand All @@ -109,15 +151,22 @@ External plugins register via Python entry points:
bloomberg = "qracer_bloomberg.adapter:BloombergAdapter"
```

On startup the registry scans entry points, loads `providers.toml` config, checks credentials, and registers enabled providers. This replaces the current hardcoded `_build_registries()` approach — significant implementation work tracked separately.
On startup `_build_registries()` scans entry points, loads `providers.toml`,
checks credentials, runs each adapter's optional `initialize()` +
`health_check()` hooks, and registers the enabled/healthy providers.

```text
App start
→ entry_points("qracer.providers") scan
→ providers.toml config load
→ entry_points("qracer.data_providers") + entry_points("qracer.llm_providers") scan
→ providers.toml config load (enabled, priority, api_key_env)
→ credentials.env check per provider
→ Missing API key → skip with warning log
→ Register enabled providers by priority
→ Optional initialize() + health_check() → unhealthy ⇒ skip with warning
→ Register surviving providers by priority

qracer serve exit
→ shutdown_all_providers(data_registry, llm_registry)
→ Exceptions from shutdown() are logged, never propagated
```

### `providers.toml` Example
Expand Down
13 changes: 13 additions & 0 deletions qracer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ def _build_registries() -> tuple[LLMRegistry, DataRegistry, list[str]]:
from qracer.llm.providers import Role
from qracer.llm.registry import LLMRegistry
from qracer.provider_catalog import discover_data_providers, discover_llm_providers
from qracer.provider_lifecycle import initialize_provider_sync

config = load_config()
llm_registry = LLMRegistry()
Expand Down Expand Up @@ -320,6 +321,9 @@ def _build_registries() -> tuple[LLMRegistry, DataRegistry, list[str]]:
mod_path, cls_name = adapter_path.rsplit(".", 1)
adapter_cls = getattr(importlib.import_module(mod_path), cls_name)
adapter = adapter_cls(api_key=api_key) if api_key else adapter_cls()
if not initialize_provider_sync(name, adapter):
warnings.append(f"{name}: failed initialize/health_check — excluded")
continue
caps = []
for cp in cap_paths:
cp_mod, cp_name = cp.rsplit(".", 1)
Expand All @@ -336,6 +340,9 @@ def _build_registries() -> tuple[LLMRegistry, DataRegistry, list[str]]:
mod_path, cls_name = adapter_path.rsplit(".", 1)
adapter_cls = getattr(importlib.import_module(mod_path), cls_name)
adapter = adapter_cls(api_key=api_key)
if not initialize_provider_sync(name, adapter):
warnings.append(f"{name}: failed initialize/health_check — excluded")
continue
roles = [Role(v) for v in role_values]
llm_registry.register(name, adapter, roles)
except Exception as exc:
Expand Down Expand Up @@ -1099,9 +1106,15 @@ def _handle_signal(signum: int, _frame: object) -> None:
click.echo(" Telegram bot: receiving commands (try /help in chat)")
click.echo(" Press Ctrl+C to stop.\n")

from qracer.provider_lifecycle import shutdown_all_providers_sync

try:
asyncio.run(server.run())
finally:
try:
shutdown_all_providers_sync(data_registry, llm_registry)
except Exception:
logger.debug("Provider shutdown raised", exc_info=True)
release(pid_path)
click.echo("qracer serve stopped.")

Expand Down
166 changes: 166 additions & 0 deletions qracer/provider_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Provider lifecycle — optional ``initialize`` / ``health_check`` / ``shutdown`` hooks.

Adapters registered via :mod:`qracer.provider_catalog` (built-ins and
entry-point plugins) can optionally implement the :class:`LifecycleProvider`
protocol to get:

* ``initialize()`` — one-shot async setup (open HTTP sessions, prime caches)
* ``health_check() -> bool`` — runtime readiness probe; ``False`` excludes the
provider from the registry instead of letting it crash on first use
* ``shutdown()`` — graceful teardown invoked on server exit

All three hooks are optional. Adapters that don't implement them are treated
as always-healthy and require no teardown — this keeps the system fully
backwards-compatible with the existing adapters in ``qracer/data/`` and
``qracer/llm/``.

Hook failures never propagate: a raising ``initialize``/``health_check`` is
treated as "unhealthy" (provider is skipped with a warning), and a raising
``shutdown`` is logged but otherwise ignored so one bad provider can't block
the rest of the teardown sweep.
"""

from __future__ import annotations

import asyncio
import inspect
import logging
from typing import Any, Iterable, Protocol, runtime_checkable

logger = logging.getLogger(__name__)


@runtime_checkable
class LifecycleProvider(Protocol):
"""Optional lifecycle hooks for data and LLM providers.

Providers are expected to implement **any subset** of these methods — the
helpers in this module use ``hasattr``/``callable`` duck-typing rather
than strict protocol conformance, so partial implementations are fine.
"""

async def initialize(self) -> None: ...

async def health_check(self) -> bool: ...

async def shutdown(self) -> None: ...


async def _maybe_await(value: Any) -> Any:
"""Await *value* if it's a coroutine/awaitable, otherwise return it."""
if inspect.isawaitable(value):
return await value
return value


async def initialize_provider(name: str, adapter: Any) -> bool:
"""Run the optional ``initialize()`` then ``health_check()`` hooks.

Returns ``True`` if the adapter is healthy (or has no hooks at all).
Returns ``False`` only when a hook raises or ``health_check()`` reports
the adapter is not ready; either case is logged as a warning.
"""
init = getattr(adapter, "initialize", None)
if callable(init):
try:
await _maybe_await(init())
except Exception as exc:
logger.warning("Provider '%s' initialize() failed: %s", name, exc)
return False

health = getattr(adapter, "health_check", None)
if callable(health):
try:
ok = await _maybe_await(health())
except Exception as exc:
logger.warning("Provider '%s' health_check() raised: %s", name, exc)
return False
if not bool(ok):
logger.warning("Provider '%s' reported unhealthy — excluded", name)
return False

return True


async def shutdown_provider(name: str, adapter: Any) -> None:
"""Run the optional ``shutdown()`` hook, swallowing any exception."""
shutdown = getattr(adapter, "shutdown", None)
if not callable(shutdown):
return
try:
await _maybe_await(shutdown())
except Exception as exc:
logger.warning("Provider '%s' shutdown() raised: %s", name, exc)


def initialize_provider_sync(name: str, adapter: Any) -> bool:
"""Synchronous wrapper for :func:`initialize_provider`.

Used by :func:`qracer.cli._build_registries`, which is called from
synchronous CLI setup code (no running event loop). If a loop is
already running we fall through to ``True`` without invoking hooks,
since ``asyncio.run()`` would fail — in practice the registry build
path is always sync.
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(initialize_provider(name, adapter))
# Running loop detected — can't safely invoke. Skip lifecycle gracefully.
logger.debug(
"Skipping lifecycle init for '%s': running event loop present in sync context",
name,
)
return True


def _iter_unique_adapters(
registries: Iterable[Any],
) -> list[tuple[str, Any]]:
"""Collect unique ``(name, adapter)`` pairs from one or more registries.

Accepts both :class:`~qracer.data.registry.DataRegistry` and
:class:`~qracer.llm.registry.LLMRegistry` — anything exposing the private
``_adapters`` or ``_providers`` dicts used by ``register()`` is fair game.
An adapter registered for multiple capabilities/roles is returned once.
"""
seen: set[int] = set()
out: list[tuple[str, Any]] = []
for reg in registries:
buckets: dict[Any, list[tuple[str, Any]]] | None = getattr(reg, "_adapters", None)
if buckets is None:
buckets = getattr(reg, "_providers", None)
if not buckets:
continue
for entries in buckets.values():
for name, adapter in entries:
key = id(adapter)
if key in seen:
continue
seen.add(key)
out.append((name, adapter))
return out


async def shutdown_all_providers(*registries: Any) -> None:
"""Shut down every unique adapter across the given registries.

Safe to call even when no adapters implement :class:`LifecycleProvider` —
adapters without a ``shutdown()`` method are silently skipped.
"""
for name, adapter in _iter_unique_adapters(registries):
await shutdown_provider(name, adapter)


def shutdown_all_providers_sync(*registries: Any) -> None:
"""Synchronous wrapper for :func:`shutdown_all_providers`.

No-op when a loop is already running — callers in async contexts should
await :func:`shutdown_all_providers` directly.
"""
try:
asyncio.get_running_loop()
except RuntimeError:
asyncio.run(shutdown_all_providers(*registries))
return
logger.debug("Skipping sync provider shutdown: running event loop present")
Loading
Loading