great-ai/great_ai/persistence/parallel_tinydb_driver.py

126 lines
4.7 KiB
Python

from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
import pandas as pd
from filelock import FileLock
from tinydb import TinyDB
from ..helper.normalise_datetime import normalise_datetime
from ..views import Filter, SortBy, Trace
from .tracing_database_driver import TracingDatabaseDriver
DEFAULT_TRACING_DB_FILENAME = "tracing_database.json"
operator_mapping = {"=": "eq", "!=": "ne", "<": "lt", "<=": "le", ">": "gt", ">=": "ge"}
class ParallelTinyDbDriver(TracingDatabaseDriver):
"""TracingDatabaseDriver with TinyDB as a backend.
Saves the database as a JSON into a single file. Highly inefficient on inserting,
not advised for production use.
A file lock protects the database across threads and independently started processes.
"""
is_production_ready = False
path_to_db = Path(DEFAULT_TRACING_DB_FILENAME)
def save(self, trace: Trace) -> str:
return self._safe_execute(lambda db: db.insert(trace.model_dump(mode="json")))
def save_batch(self, documents: List[Trace]) -> List[str]:
traces = [d.model_dump(mode="json") for d in documents]
return self._safe_execute(lambda db: db.insert_multiple(traces))
def get(self, id: str) -> Optional[Trace]:
value = self._safe_execute(lambda db: db.get(lambda d: d["trace_id"] == id))
if value:
value = Trace.model_validate(value)
return value
def query(
self,
*,
skip: int = 0,
take: Optional[int] = None,
conjunctive_filters: Sequence[Filter] = [],
conjunctive_tags: Sequence[str] = [],
since: Optional[datetime] = None,
until: Optional[datetime] = None,
has_feedback: Optional[bool] = None,
sort_by: Sequence[SortBy] = [],
) -> Tuple[List[Trace], int]:
since = normalise_datetime(since) if since is not None else None
until = normalise_datetime(until) if until is not None else None
def does_match(d: Dict[str, Any]) -> bool:
created = normalise_datetime(datetime.fromisoformat(d["created"]))
return (
not set(conjunctive_tags) - set(d["tags"])
and (since is None or created >= since)
and (until is None or created <= until)
and (
has_feedback is None or has_feedback == (d["feedback"] is not None)
)
)
documents = self._safe_execute(lambda db: db.search(does_match))
if not documents:
return [], 0
traces: List[Trace] = [Trace.model_validate(d) for d in documents]
# The DataFrame keeps its default 0..n-1 index, so filtering/sorting below
# preserve the row labels that index back into `traces`. This avoids
# reconstructing Traces from the lossy flattened rows.
df = pd.DataFrame([t.to_flat_dict() for t in traces])
for f in conjunctive_filters:
operator = f.operator.lower()
if operator in operator_mapping:
df = df.loc[
getattr(df[f.property], operator_mapping[f.operator])(f.value)
]
elif operator == "contains":
pattern = f.value_as_text.casefold()
df = df.loc[
df[f.property].map(
lambda value: (
pattern in value.casefold()
if isinstance(value, str)
else not isinstance(f.value, str) and value == f.value
)
)
]
if sort_by:
df.sort_values(
[col.column_id for col in sort_by],
ascending=[col.direction == "asc" for col in sort_by],
inplace=True,
)
count = len(df)
result = df.iloc[skip:] if take is None else df.iloc[skip : skip + take]
return [traces[i] for i in result.index], count
def update(self, id: str, new_version: Trace) -> None:
self._safe_execute(
lambda db: db.update(
new_version.model_dump(mode="json"), lambda d: d["trace_id"] == id
)
)
def delete(self, id: str) -> None:
self._safe_execute(lambda db: db.remove(lambda d: d["trace_id"] == id))
def delete_batch(self, ids: List[str]) -> None:
selected = set(ids)
self._safe_execute(lambda db: db.remove(lambda d: d["trace_id"] in selected))
def _safe_execute(self, func: Callable[[TinyDB], Any]) -> Any:
with FileLock(str(self.path_to_db.resolve()) + ".lock"):
with TinyDB(self.path_to_db) as db:
return func(db)