AI_Agro_Support/pipeline/resources.py
Arsham Mirehvandi 099d1e0af9 Initial commit
2026-08-22 08:47:56 +02:00

201 lines
8.0 KiB
Python

"""Resources shared across every job in a batch run.
A single-field CLI run opened one SQL connection, one Weaviate client, and
made its two LLM calls without needing to coordinate with anyone else. The
batch runs many jobs concurrently across a thread pool, so several things
that used to be "create it, use it, close it" per field now need to be
long-lived, shared, and safe to touch from multiple threads at once:
- pyodbc connections must not be shared across threads, so each worker thread
gets its own, created lazily and reused for every job it picks up.
- The Weaviate client is documented as safe for concurrent query use, so one
instance is opened for the whole batch instead of one per field.
- `near_text` search embeds through the same Gemini API key as the two LLM
calls (`ProductProfile` is vectorised with text2vec-palm /
gemini-embedding-001), so a single semaphore + rate limiter covers all three
Gemini-backed calls per job instead of treating "LLM" and "Weaviate" as
independent budgets.
- The `products` table and the `ProductJsonBuilder` per-product cache are
loaded/shared once instead of once per field.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
from pipeline.config import LlmSettings, Settings
from pipeline.db import connect_raw
from pipeline.prompts import PromptRegistry
from pipeline.retry import call_with_llm_retry, call_with_weaviate_retry
from pipeline.stages.llm import call_llm as _raw_call_llm
from pipeline.stages.product_json import ProductJsonBuilder
from pipeline.stages.products import ProductCandidate, ProductIndex
from pipeline.stages.vector import RecommendedProduct, connect_client
from pipeline.stages.vector import search_products as _raw_search_products
logger = logging.getLogger(__name__)
class RateLimiter:
"""
Thread-safe pacing limiter: spreads calls out to at most N per minute.
This paces call *starts* evenly (a leaky bucket, not a bursty token
bucket with saved-up credit) — the simplest thing that reliably keeps a
pool of worker threads under an external rate limit, which matters more
here than allowing occasional bursts.
"""
def __init__(self, requests_per_minute: int) -> None:
self._interval = 60.0 / requests_per_minute if requests_per_minute > 0 else 0.0
self._lock = threading.Lock()
self._next_allowed = 0.0
def acquire(self) -> None:
if self._interval <= 0:
return
with self._lock:
now = time.monotonic()
wait = self._next_allowed - now
if wait > 0:
time.sleep(wait)
now = time.monotonic()
self._next_allowed = max(now, self._next_allowed) + self._interval
class Resources:
"""Shared, thread-safe state for one batch run. Create once, close once."""
def __init__(self, settings: Settings, prompts: PromptRegistry) -> None:
self.settings = settings
self.prompts = prompts
self._local = threading.local()
self._connections: list[Any] = []
self._connections_lock = threading.Lock()
self.sql_sem = threading.Semaphore(settings.concurrency.sql_limit)
self.gemini_sem = threading.Semaphore(settings.concurrency.gemini_limit)
self.gemini_rate_limiter = RateLimiter(settings.concurrency.gemini_requests_per_minute)
self._weaviate_client: Any = None
self._weaviate_lock = threading.Lock()
self.product_index: ProductIndex | None = None
self._builder_cache: dict[tuple[str, str], ProductJsonBuilder] = {}
self._builder_lock = threading.Lock()
# -- SQL -----------------------------------------------------------------
def sql_connection(self) -> Any:
"""
Return this thread's SQL connection, opening one on first use.
Not gated by `sql_sem`: with one connection per worker thread kept
open for the batch's whole lifetime, gating *creation* would only
throttle how fast the pool warms up, and gating it for the
connection's entire lifetime would deadlock as soon as `sql_limit` is
set below `workers` (the remaining threads would block forever on a
permit nothing ever releases). `sql_sem` instead bounds how many jobs
may run their SQL-heavy phase at the same instant — see
`pipeline.job.run_job`, which acquires it around each phase, not
around the connection.
"""
conn = getattr(self._local, "conn", None)
if conn is None:
conn = connect_raw(self.settings.sql)
self._local.conn = conn
with self._connections_lock:
self._connections.append(conn)
return conn
def load_product_index(self) -> ProductIndex:
"""Load the whole `products` table once; call before starting the pool."""
self.product_index = ProductIndex.load(self.sql_connection())
return self.product_index
def product_json_builder(self, crop_english: str, disease_english: str) -> ProductJsonBuilder:
"""
One `ProductJsonBuilder` per (crop, disease), shared by every field
growing that crop with that disease across all worker threads.
"""
if self.product_index is None:
raise RuntimeError("load_product_index() must run before product_json_builder().")
key = (crop_english, disease_english)
with self._builder_lock:
builder = self._builder_cache.get(key)
if builder is None:
builder = ProductJsonBuilder(self.product_index, crop_english, disease_english)
self._builder_cache[key] = builder
return builder
# -- Weaviate / Gemini -----------------------------------------------------
def _weaviate(self) -> Any:
if self._weaviate_client is None:
with self._weaviate_lock:
if self._weaviate_client is None:
self._weaviate_client = connect_client(self.settings)
return self._weaviate_client
def call_llm(
self,
settings: Settings,
llm_cfg: LlmSettings,
system_prompt: str,
user_content: str,
json_output: bool = False,
) -> str:
"""
Rate-limited, retried, concurrency-bounded drop-in for
`pipeline.stages.llm.call_llm`.
Signature-compatible so it can be passed as `synthesize_queries(...,
llm_call=resources.call_llm)` / `generate_advice(...,
llm_call=resources.call_llm)`.
"""
def _attempt() -> str:
with self.gemini_sem:
self.gemini_rate_limiter.acquire()
return _raw_call_llm(settings, llm_cfg, system_prompt, user_content, json_output)
return call_with_llm_retry(self.settings.retry, _attempt)
def search_products(
self,
queries: list[str],
candidates: list[ProductCandidate],
) -> list[RecommendedProduct]:
"""Rate-limited, retried wrapper around `pipeline.stages.vector.search_products`."""
def _attempt() -> list[RecommendedProduct]:
with self.gemini_sem:
self.gemini_rate_limiter.acquire()
return _raw_search_products(self._weaviate(), queries, candidates)
return call_with_weaviate_retry(self.settings.retry, _attempt)
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
"""Close every pooled SQL connection and the shared Weaviate client."""
with self._connections_lock:
for conn in self._connections:
try:
conn.close()
except Exception:
logger.warning("Error closing a pooled SQL connection.", exc_info=True)
self._connections.clear()
if self._weaviate_client is not None:
try:
self._weaviate_client.close()
except Exception:
logger.warning("Error closing the Weaviate client.", exc_info=True)
self._weaviate_client = None