perf(benchmark): bulk-insert the SQLite seed corpus in one transaction (#2947)
dev/benchmarks/omnigent/seed.py seeded the benchmark corpus through the production store ORM API one row at a time (~2M single-row INSERTs, ~20k commits, each preceded by throwaway PRAGMAs on session open), taking ~6-10 min on CI. The benchmark only measures the store read path, so the write strategy does not taint what's measured provided the resulting corpus is the same shape. Add a SQLAlchemy Core bulk-insert fast path (_seed_via_core) that writes the whole corpus in one transaction via ~10 batched executemany flushes (1 commit instead of ~20k). It uses the ORM Table objects so Uuid16 binds bare-hex to 16 bytes byte-identically to the store, computes title_hash explicitly (Python defaults don't fire under executemany, and sets all kind/status columns explicitly. The schema at head carries no FK constraints (migration p1a2b3c4d5e6 dropped them all), so insert order is free under PRAGMA foreign_keys=ON. Dialect-gated: SQLite uses the fast path; every other dialect (e.g. the nightly Postgres benchmark) falls back to the existing store-API loop (_seed_via_store), extracted verbatim, so behavior there stays identical. Byte-stable: same RNG seed/counts/_FRAGMENTS, same generate_*_id calls, same per-session draw order (title first, then items), same 0-based position allocation, same label stamped on the last session, same _meta_value config string. Item data/search_text are built byte-identical to MessageData.model_dump(exclude_none=True) + extract_search_text (the slow path keeps _make_items as the single source of truth). The fast path item build bypasses pydantic (building plain dicts) to keep the 1M-item Python phase cheap; a byte-stability test pins both paths to identical corpora. Idempotency preserved: the reuse-skip check, --reseed, and --print-head work unchanged; ensure_user(local) and the seed-meta label upsert are mirrored via sqlite_insert.on_conflict_do_*. Target: ~20-30s end-to-end (was ~6-10 min) for the 5000x200 corpus; measured ~27s locally. Scope: seed.py + a new test file only; no product store/db code under omnigent/stores/ or omnigent/db/ touched. EOF )
This commit is contained in:
@@ -29,6 +29,8 @@ Run standalone::
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -36,7 +38,37 @@ from pathlib import Path
|
||||
# Allow ``uv run <path>`` (no package context) to import omnigent + siblings.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from omnigent.db.utils import _get_head_db_revision, generate_agent_id
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
from omnigent.db.db_models import (
|
||||
LABEL_VALUE_MAX_LEN,
|
||||
SqlAgent,
|
||||
SqlConversation,
|
||||
SqlConversationItem,
|
||||
SqlConversationLabel,
|
||||
SqlConversationMetadata,
|
||||
SqlSessionPermission,
|
||||
SqlUser,
|
||||
current_workspace_id,
|
||||
)
|
||||
from omnigent.db.enum_codecs import (
|
||||
encode_agent_kind,
|
||||
encode_conversation_kind,
|
||||
encode_item_status,
|
||||
encode_item_type,
|
||||
)
|
||||
from omnigent.db.utils import (
|
||||
_FTS_TABLE,
|
||||
_get_head_db_revision,
|
||||
generate_agent_id,
|
||||
generate_conversation_id,
|
||||
generate_item_id,
|
||||
get_or_create_engine,
|
||||
now_epoch,
|
||||
strip_nul_bytes,
|
||||
)
|
||||
from omnigent.entities import MessageData, NewConversationItem
|
||||
from omnigent.server.auth import LEVEL_OWNER, RESERVED_USER_LOCAL
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore
|
||||
@@ -48,6 +80,7 @@ _SEED_META_LABEL = "omni_bench_seed"
|
||||
|
||||
# Fixed identifiers so the corpus is byte-stable across runs at a given config.
|
||||
_AGENT_NAME = "bench-agent"
|
||||
_AGENT_BUNDLE = "bench/seed" # never validated on the read path
|
||||
_DEFAULT_SESSIONS = 5000
|
||||
_DEFAULT_ITEMS = 50
|
||||
_DEFAULT_RNG_SEED = 1234
|
||||
@@ -67,6 +100,19 @@ _FRAGMENTS = (
|
||||
"reproduce the elicitation race on reconnect",
|
||||
)
|
||||
|
||||
# FTS5 mirror row written per item on SQLite (must match omnigent.db.utils
|
||||
# ``insert_fts`` / ``_FTS_TABLE``). Bound by name in :data:`_FTS_INSERT_SQL`.
|
||||
_FTS_INSERT_SQL = text(
|
||||
f"INSERT INTO {_FTS_TABLE} (item_id, conversation_id, search_text) "
|
||||
"VALUES (:item_id, :cid, :st)"
|
||||
)
|
||||
|
||||
# Rows buffered per Core ``executemany`` flush. Only the item/FTS buffers
|
||||
# (1:1 with items) approach this; the per-session tables are held in full
|
||||
# (a few thousand rows) and inserted in one shot each. 100k keeps a 5000×200
|
||||
# corpus to ~10 flushes per table and bounds peak memory to a few tens of MB.
|
||||
_CORE_ITEM_CHUNK = 100_000
|
||||
|
||||
|
||||
def _meta_value(sessions: int, items_per_session: int, rng_seed: int, head: str) -> str:
|
||||
"""Serialize the corpus config into the seed-marker label value.
|
||||
@@ -99,17 +145,23 @@ def _make_items(rng: random.Random, count: int) -> list[NewConversationItem]:
|
||||
"""
|
||||
items: list[NewConversationItem] = []
|
||||
for i in range(count):
|
||||
text = f"{rng.choice(_FRAGMENTS)} (item {i})"
|
||||
text_str = f"{rng.choice(_FRAGMENTS)} (item {i})"
|
||||
items.append(
|
||||
NewConversationItem(
|
||||
type="message",
|
||||
response_id=f"resp_seed_{i}",
|
||||
data=MessageData(role="user", content=[{"type": "input_text", "text": text}]),
|
||||
data=MessageData(role="user", content=[{"type": "input_text", "text": text_str}]),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _progress(s: int, sessions: int) -> None:
|
||||
"""Print a coarse progress line every 10% (only for sizeable corpora)."""
|
||||
if sessions >= 100 and s % (sessions // 10) == 0 and s:
|
||||
print(f"seed: {s}/{sessions} sessions")
|
||||
|
||||
|
||||
def seed(
|
||||
db_uri: str,
|
||||
*,
|
||||
@@ -117,6 +169,7 @@ def seed(
|
||||
items_per_session: int = _DEFAULT_ITEMS,
|
||||
rng_seed: int = _DEFAULT_RNG_SEED,
|
||||
reseed: bool = False,
|
||||
_fast: bool | None = None,
|
||||
) -> int:
|
||||
"""Seed *sessions* sessions × *items_per_session* items into *db_uri*.
|
||||
|
||||
@@ -131,10 +184,21 @@ def seed(
|
||||
:param items_per_session: Conversation items appended to each session.
|
||||
:param rng_seed: Seed for the deterministic text RNG.
|
||||
:param reseed: Seed even when a matching corpus is already present.
|
||||
:param _fast: Override the write strategy. ``None`` (default) uses the
|
||||
bulk-insert Core fast path for SQLite and the store-API loop for every
|
||||
other dialect; ``True`` forces the fast path (falls back to the loop on
|
||||
non-SQLite); ``False`` forces the store-API loop (used by the
|
||||
byte-stability test to compare both paths on SQLite).
|
||||
:returns: The number of sessions created (0 when a matching seed is reused).
|
||||
"""
|
||||
conv = SqlAlchemyConversationStore(db_uri)
|
||||
perms = SqlAlchemyPermissionStore(db_uri)
|
||||
|
||||
dialect = make_url(db_uri).get_backend_name()
|
||||
use_fast = (dialect == "sqlite") if _fast is None else bool(_fast)
|
||||
if use_fast and dialect != "sqlite":
|
||||
# The fast path is SQLite-only (FTS5 + single-transaction bulk insert);
|
||||
# a forced fast request on another dialect degrades to the store loop.
|
||||
use_fast = False
|
||||
|
||||
# Read the current schema head at runtime (no DB contacted) and fold it into
|
||||
# the reuse marker, so a corpus from an older schema is auto-reseeded.
|
||||
@@ -149,6 +213,44 @@ def seed(
|
||||
print(f"seed: existing corpus differs ({existing!r} != {want!r}); pass --reseed")
|
||||
return 0
|
||||
|
||||
if use_fast:
|
||||
n = _seed_via_core(
|
||||
db_uri,
|
||||
sessions=sessions,
|
||||
items_per_session=items_per_session,
|
||||
rng_seed=rng_seed,
|
||||
want=want,
|
||||
)
|
||||
else:
|
||||
perms = SqlAlchemyPermissionStore(db_uri)
|
||||
n = _seed_via_store(
|
||||
conv,
|
||||
perms,
|
||||
sessions=sessions,
|
||||
items_per_session=items_per_session,
|
||||
rng_seed=rng_seed,
|
||||
want=want,
|
||||
)
|
||||
|
||||
print(f"seed: created {n} sessions × {items_per_session} items ({want})")
|
||||
return n
|
||||
|
||||
|
||||
def _seed_via_store(
|
||||
conv: SqlAlchemyConversationStore,
|
||||
perms: SqlAlchemyPermissionStore,
|
||||
*,
|
||||
sessions: int,
|
||||
items_per_session: int,
|
||||
rng_seed: int,
|
||||
want: str,
|
||||
) -> int:
|
||||
"""Seed through the production store ORM API (one row/commit at a time).
|
||||
|
||||
This is the original path and the only one used on non-SQLite dialects
|
||||
(e.g. the nightly Postgres benchmark). It is kept verbatim so behavior
|
||||
there stays identical.
|
||||
"""
|
||||
perms.ensure_user(RESERVED_USER_LOCAL)
|
||||
rng = random.Random(rng_seed)
|
||||
|
||||
@@ -157,7 +259,7 @@ def seed(
|
||||
created = conv.create_session_with_agent(
|
||||
agent_id=generate_agent_id(),
|
||||
agent_name=_AGENT_NAME,
|
||||
agent_bundle_location="bench/seed", # never validated on the read path
|
||||
agent_bundle_location=_AGENT_BUNDLE,
|
||||
agent_description=None,
|
||||
title=f"bench session {s}: {rng.choice(_FRAGMENTS)}",
|
||||
)
|
||||
@@ -166,8 +268,7 @@ def seed(
|
||||
perms.grant(RESERVED_USER_LOCAL, sid, LEVEL_OWNER)
|
||||
if items_per_session:
|
||||
conv.append(sid, _make_items(rng, items_per_session))
|
||||
if sessions >= 100 and s % (sessions // 10) == 0 and s:
|
||||
print(f"seed: {s}/{sessions} sessions")
|
||||
_progress(s, sessions)
|
||||
|
||||
# Stamp the corpus config on the LAST (newest) session — that's the one
|
||||
# ``_existing_seed_meta``'s default desc listing returns, so the reuse
|
||||
@@ -175,7 +276,211 @@ def seed(
|
||||
if last_sid:
|
||||
conv.set_labels(last_sid, {_SEED_META_LABEL: want})
|
||||
|
||||
print(f"seed: created {sessions} sessions × {items_per_session} items ({want})")
|
||||
return sessions
|
||||
|
||||
|
||||
def _seed_via_core(
|
||||
db_uri: str,
|
||||
*,
|
||||
sessions: int,
|
||||
items_per_session: int,
|
||||
rng_seed: int,
|
||||
want: str,
|
||||
) -> int:
|
||||
"""Seed the entire corpus in one transaction via SQLAlchemy Core.
|
||||
|
||||
Writes the same DB rows the store-API loop would, but batches them into a
|
||||
handful of ``executemany`` flushes under a single ``BEGIN``/``COMMIT`` —
|
||||
~10 batched INSERTs and 1 commit instead of ~2M single-row INSERTs and
|
||||
~20k commits. The schema at head carries no FK constraints (migration
|
||||
``p1a2b3c4d5e6`` dropped them all), so insert order is free and the
|
||||
engine's ``PRAGMA foreign_keys=ON`` enforces nothing.
|
||||
|
||||
The RNG draw order and the per-row serialization are kept identical to the
|
||||
store path: per session, the title fragment is drawn first, then the
|
||||
``items_per_session`` item fragments. Item ``data`` is
|
||||
``strip_nul_bytes(json.dumps(...))`` (default separators) of a plain dict
|
||||
that mirrors ``MessageData.model_dump(exclude_none=True)``, and
|
||||
``search_text`` mirrors ``extract_search_text``'s message branch — both
|
||||
built directly (no pydantic) so the 1M-item Python build stays cheap. So a
|
||||
corpus seeded here is the same shape (same ids-space, same text, same
|
||||
positions, same labels) as one seeded through the store — only the write
|
||||
strategy differs. ``tests/benchmarks/test_seed_fast_path.py`` pins the two
|
||||
paths to identical corpora.
|
||||
"""
|
||||
engine = get_or_create_engine(db_uri)
|
||||
ws = current_workspace_id()
|
||||
rng = random.Random(rng_seed)
|
||||
|
||||
# Per-session scalar rows (conversations/agents/metadata/permissions) are
|
||||
# small (a few thousand); hold them in full and insert each in one shot.
|
||||
conv_rows: list[dict] = []
|
||||
agent_rows: list[dict] = []
|
||||
meta_rows: list[dict] = []
|
||||
perm_rows: list[dict] = []
|
||||
# Items + FTS mirror are 1:1 with items and dominate the volume (1M+ for a
|
||||
# full seed); stream them in chunks to bound memory while staying in the
|
||||
# single transaction.
|
||||
item_buf: list[dict] = []
|
||||
fts_buf: list[dict] = []
|
||||
last_sid = ""
|
||||
|
||||
with engine.begin() as conn:
|
||||
# ensure_user("local") — ON CONFLICT DO NOTHING, mirroring the store.
|
||||
conn.execute(
|
||||
sqlite_insert(SqlUser)
|
||||
.values(workspace_id=ws, id=RESERVED_USER_LOCAL, is_admin=False)
|
||||
.on_conflict_do_nothing(index_elements=["workspace_id", "id"])
|
||||
)
|
||||
|
||||
for s in range(sessions):
|
||||
now = now_epoch()
|
||||
agent_id = generate_agent_id()
|
||||
sid = generate_conversation_id()
|
||||
# RNG draw order matches the store path: title first, then items.
|
||||
title = f"bench session {s}: {rng.choice(_FRAGMENTS)}"
|
||||
last_sid = sid
|
||||
|
||||
conv_rows.append(
|
||||
{
|
||||
"workspace_id": ws,
|
||||
"id": sid,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"title": title,
|
||||
"title_hash": hashlib.sha256(title.encode("utf-8")).digest()[:16],
|
||||
"parent_conversation_id": None,
|
||||
"root_conversation_id": sid,
|
||||
"next_position": items_per_session,
|
||||
"agent_id": agent_id,
|
||||
"session_overrides": None,
|
||||
"archived": False,
|
||||
}
|
||||
)
|
||||
agent_rows.append(
|
||||
{
|
||||
"workspace_id": ws,
|
||||
"id": agent_id,
|
||||
"created_at": now,
|
||||
"name": _AGENT_NAME,
|
||||
"bundle_location": _AGENT_BUNDLE,
|
||||
"version": 1,
|
||||
"kind": encode_agent_kind("session"),
|
||||
"description": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
)
|
||||
meta_rows.append(
|
||||
{
|
||||
"workspace_id": ws,
|
||||
"id": sid,
|
||||
"kind": encode_conversation_kind("default"),
|
||||
"runner_id": None,
|
||||
"host_id": None,
|
||||
"sub_agent_name": None,
|
||||
"external_session_id": None,
|
||||
"session_state": None,
|
||||
"session_usage": None,
|
||||
"terminal_launch_args": None,
|
||||
"workspace": None,
|
||||
"git_branch": None,
|
||||
"runner_last_seen": None,
|
||||
"live_status": None,
|
||||
"pending_elicitation_count": None,
|
||||
}
|
||||
)
|
||||
perm_rows.append(
|
||||
{
|
||||
"workspace_id": ws,
|
||||
"user_id": RESERVED_USER_LOCAL,
|
||||
"conversation_id": sid,
|
||||
"level": LEVEL_OWNER,
|
||||
}
|
||||
)
|
||||
|
||||
if items_per_session:
|
||||
# Build item payloads straight to dicts (no pydantic) so the
|
||||
# 1M-item Python build stays cheap. The output is byte-identical
|
||||
# to the store path: the text format mirrors ``_make_items``,
|
||||
# the ``data`` dict mirrors ``MessageData.model_dump(exclude_none=
|
||||
# True)``, and ``search`` mirrors ``extract_search_text``'s
|
||||
# message branch. The byte-stability test
|
||||
# (tests/benchmarks/test_seed_fast_path.py) pins this to the
|
||||
# store path's rows. ``_make_items`` is still used by the slow
|
||||
# path above, so the text format stays single-sourced there.
|
||||
for i in range(items_per_session):
|
||||
text_str = f"{rng.choice(_FRAGMENTS)} (item {i})"
|
||||
data_dict = {
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": text_str}],
|
||||
}
|
||||
data = strip_nul_bytes(json.dumps(data_dict))
|
||||
search = strip_nul_bytes(
|
||||
" ".join(
|
||||
block["text"]
|
||||
for block in data_dict["content"]
|
||||
if isinstance(block, dict) and block.get("text")
|
||||
)
|
||||
)
|
||||
item_id = generate_item_id("message")
|
||||
item_buf.append(
|
||||
{
|
||||
"workspace_id": ws,
|
||||
"conversation_id": sid,
|
||||
"id": item_id,
|
||||
"response_id": f"resp_seed_{i}",
|
||||
"created_at": now,
|
||||
"status": encode_item_status("completed"),
|
||||
"position": i,
|
||||
"type": encode_item_type("message"),
|
||||
"data": data,
|
||||
"search_text": search,
|
||||
"created_by": None,
|
||||
}
|
||||
)
|
||||
fts_buf.append({"item_id": item_id, "cid": sid, "st": search})
|
||||
|
||||
if len(item_buf) >= _CORE_ITEM_CHUNK:
|
||||
conn.execute(SqlConversationItem.__table__.insert(), item_buf)
|
||||
conn.execute(_FTS_INSERT_SQL, fts_buf)
|
||||
item_buf.clear()
|
||||
fts_buf.clear()
|
||||
|
||||
_progress(s, sessions)
|
||||
|
||||
if item_buf:
|
||||
conn.execute(SqlConversationItem.__table__.insert(), item_buf)
|
||||
conn.execute(_FTS_INSERT_SQL, fts_buf)
|
||||
item_buf.clear()
|
||||
fts_buf.clear()
|
||||
|
||||
# No FKs at head → order is free; insert the per-session scalar tables
|
||||
# now (after the streamed items) in one shot each.
|
||||
conn.execute(SqlConversation.__table__.insert(), conv_rows)
|
||||
conn.execute(SqlAgent.__table__.insert(), agent_rows)
|
||||
conn.execute(SqlConversationMetadata.__table__.insert(), meta_rows)
|
||||
conn.execute(SqlSessionPermission.__table__.insert(), perm_rows)
|
||||
|
||||
# Stamp the corpus config on the LAST (newest) session, matching the
|
||||
# store path's ``set_labels`` upsert (clamped to LABEL_VALUE_MAX_LEN).
|
||||
if last_sid:
|
||||
label_now = now_epoch()
|
||||
label_value = want[:LABEL_VALUE_MAX_LEN]
|
||||
conn.execute(
|
||||
sqlite_insert(SqlConversationLabel)
|
||||
.values(
|
||||
workspace_id=ws,
|
||||
conversation_id=last_sid,
|
||||
key=_SEED_META_LABEL,
|
||||
value=label_value,
|
||||
updated_at=label_now,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=["workspace_id", "conversation_id", "key"],
|
||||
set_={"value": label_value, "updated_at": label_now},
|
||||
)
|
||||
)
|
||||
|
||||
return sessions
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Tests for the benchmark corpus seeder's bulk-insert fast path.
|
||||
|
||||
The seeder has two write strategies selected by dialect:
|
||||
|
||||
* the production store-ORM loop (one row/commit at a time) — the only path on
|
||||
non-SQLite dialects such as the nightly Postgres benchmark; and
|
||||
* a SQLAlchemy-Core bulk-insert fast path (one transaction, ~10 batched
|
||||
``executemany`` flushes) — SQLite only.
|
||||
|
||||
These tests pin the fast path to the store path's contract: the same corpus
|
||||
*shape* (row counts, RNG-determined text, JSON ``data`` blobs, position
|
||||
allocation, labels) so the read journeys measure the same volume either way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
from omnigent.db.utils import get_or_create_engine
|
||||
from omnigent.server.auth import LEVEL_OWNER, RESERVED_USER_LOCAL
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _count(engine, table: str) -> int:
|
||||
with engine.connect() as conn:
|
||||
return conn.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar_one()
|
||||
|
||||
|
||||
def _s_of(title: str) -> int:
|
||||
"""Recover the session index from a seeded title ``"bench session {s}: ..."``."""
|
||||
return int(title.split(":", 1)[0][len("bench session ") :])
|
||||
|
||||
|
||||
def _titles_ordered(engine):
|
||||
"""Titles keyed by session index s, sorted (RNG-determined, order-sensitive)."""
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text("SELECT title FROM conversations")).all()
|
||||
return sorted((_s_of(t), t) for (t,) in rows)
|
||||
|
||||
|
||||
def _item_text_ordered(engine):
|
||||
"""(session s, position, search_text) sorted — the RNG draw sequence in order."""
|
||||
with engine.connect() as conn:
|
||||
convs = conn.execute(text("SELECT id, title FROM conversations")).all()
|
||||
s_by_cid = {cid: _s_of(t) for cid, t in convs}
|
||||
items = conn.execute(
|
||||
text("SELECT conversation_id, position, search_text FROM conversation_items")
|
||||
).all()
|
||||
return sorted((s_by_cid[cid], pos, st) for cid, pos, st in items)
|
||||
|
||||
|
||||
def _item_data_ordered(engine):
|
||||
"""(session s, position, data-JSON) sorted — proves the serialization matches."""
|
||||
with engine.connect() as conn:
|
||||
convs = conn.execute(text("SELECT id, title FROM conversations")).all()
|
||||
s_by_cid = {cid: _s_of(t) for cid, t in convs}
|
||||
items = conn.execute(
|
||||
text("SELECT conversation_id, position, data FROM conversation_items")
|
||||
).all()
|
||||
return sorted((s_by_cid[cid], pos, d) for cid, pos, d in items)
|
||||
|
||||
|
||||
# ── fast path: row counts + read path through the store API ──
|
||||
|
||||
|
||||
def test_seed_fast_path_row_counts_and_read_path(tmp_path: Path) -> None:
|
||||
"""The fast path writes every table the store path would, listable as "local"."""
|
||||
from dev.benchmarks.omnigent import seed as seed_mod
|
||||
|
||||
db_uri = f"sqlite:///{tmp_path / 'fast.db'}"
|
||||
|
||||
created = seed_mod.seed(db_uri, sessions=50, items_per_session=10, _fast=True)
|
||||
assert created == 50
|
||||
|
||||
engine = get_or_create_engine(db_uri)
|
||||
assert _count(engine, "conversations") == 50
|
||||
assert _count(engine, "conversation_items") == 500
|
||||
assert _count(engine, "conversation_items_fts") == 500
|
||||
assert _count(engine, "agents") == 50
|
||||
assert _count(engine, "omnigent_conversation_metadata") == 50
|
||||
assert _count(engine, "session_permissions") == 50
|
||||
assert _count(engine, "users") == 1
|
||||
assert _count(engine, "conversation_labels") == 1
|
||||
|
||||
with engine.connect() as conn:
|
||||
# next_position advanced to items_per_session on every conversation.
|
||||
nps = conn.execute(text("SELECT next_position FROM conversations")).all()
|
||||
assert all(r[0] == 10 for r in nps)
|
||||
# every grant is LEVEL_OWNER for the reserved "local" user.
|
||||
levels = conn.execute(
|
||||
text("SELECT DISTINCT level FROM session_permissions WHERE user_id = :u"),
|
||||
{"u": RESERVED_USER_LOCAL},
|
||||
).all()
|
||||
assert levels == [(LEVEL_OWNER,)]
|
||||
# agent kind = session (2), metadata kind = default (1), archived = 0.
|
||||
assert {r[0] for r in conn.execute(text("SELECT kind FROM agents")).all()} == {2}
|
||||
assert {
|
||||
r[0]
|
||||
for r in conn.execute(text("SELECT kind FROM omnigent_conversation_metadata")).all()
|
||||
} == {1}
|
||||
assert {r[0] for r in conn.execute(text("SELECT archived FROM conversations")).all()} == {
|
||||
0
|
||||
}
|
||||
# the seed-meta label is present and carries the corpus config.
|
||||
labels = conn.execute(
|
||||
text("SELECT value FROM conversation_labels WHERE key = :k"),
|
||||
{"k": seed_mod._SEED_META_LABEL},
|
||||
).all()
|
||||
assert len(labels) == 1
|
||||
assert labels[0][0].startswith("sessions=50;items=10;")
|
||||
|
||||
# Read path the benchmark measures (store API, not raw SQL).
|
||||
conv = SqlAlchemyConversationStore(db_uri)
|
||||
listing = conv.list_conversations(
|
||||
limit=100,
|
||||
agent_name="bench-agent",
|
||||
accessible_by=RESERVED_USER_LOCAL,
|
||||
has_agent_id=True,
|
||||
)
|
||||
assert len(listing.data) == 50 # all seeded sessions listable as "local"
|
||||
items = conv.list_items(listing.data[0].id, limit=100)
|
||||
assert len(items.data) == 10
|
||||
# items come back in position order (response_id encodes the per-session index).
|
||||
assert [i.response_id for i in items.data] == [f"resp_seed_{i}" for i in range(10)]
|
||||
|
||||
# Idempotent: a matching re-seed is a no-op (reuse-skip path).
|
||||
assert seed_mod.seed(db_uri, sessions=50, items_per_session=10, _fast=True) == 0
|
||||
|
||||
|
||||
# ── byte-stability: fast path replicates the store path's corpus ──
|
||||
|
||||
|
||||
def test_seed_fast_path_corpus_matches_store_path(tmp_path: Path) -> None:
|
||||
"""Same config + RNG seed → identical corpus shape via either write path.
|
||||
|
||||
The uuid4 ids are fresh per run, so we compare the RNG-determined content
|
||||
(titles, per-session search_text in draw order, JSON ``data`` blobs) and the
|
||||
row counts — proving the fast path draws the RNG in the same order and
|
||||
serializes rows identically to the store ORM loop.
|
||||
"""
|
||||
from dev.benchmarks.omnigent import seed as seed_mod
|
||||
|
||||
cfg = {"sessions": 50, "items_per_session": 10, "rng_seed": 1234}
|
||||
fast_uri = f"sqlite:///{tmp_path / 'fast.db'}"
|
||||
slow_uri = f"sqlite:///{tmp_path / 'slow.db'}"
|
||||
|
||||
assert seed_mod.seed(fast_uri, _fast=True, **cfg) == 50
|
||||
assert seed_mod.seed(slow_uri, _fast=False, **cfg) == 50 # force the store loop on SQLite
|
||||
|
||||
fe = get_or_create_engine(fast_uri)
|
||||
se = get_or_create_engine(slow_uri)
|
||||
|
||||
for table in (
|
||||
"conversations",
|
||||
"conversation_items",
|
||||
"conversation_items_fts",
|
||||
"agents",
|
||||
"omnigent_conversation_metadata",
|
||||
"session_permissions",
|
||||
"users",
|
||||
"conversation_labels",
|
||||
):
|
||||
assert _count(fe, table) == _count(se, table), table
|
||||
|
||||
# RNG draw order + content match exactly (order-preserving).
|
||||
assert _titles_ordered(fe) == _titles_ordered(se)
|
||||
assert _item_text_ordered(fe) == _item_text_ordered(se)
|
||||
# JSON serialization (default separators, exclude_none) matches byte-for-byte.
|
||||
assert _item_data_ordered(fe) == _item_data_ordered(se)
|
||||
|
||||
|
||||
# ── non-SQLite slow path (skipped unless a non-SQLite DB is provided) ──
|
||||
|
||||
|
||||
def test_seed_slow_path_non_sqlite() -> None:
|
||||
"""The store-API loop remains the path on non-SQLite dialects.
|
||||
|
||||
Skipped unless ``OMNIGENT_BENCH_NONSQLITE_URI`` points at a real non-SQLite
|
||||
DB (e.g. the nightly Postgres benchmark). The fast path is SQLite-only, so
|
||||
this guards the fallback the nightly run relies on.
|
||||
"""
|
||||
uri = os.environ.get("OMNIGENT_BENCH_NONSQLITE_URI")
|
||||
if not uri:
|
||||
pytest.skip(
|
||||
"set OMNIGENT_BENCH_NONSQLITE_URI to a non-SQLite URI to exercise the slow seed path"
|
||||
)
|
||||
assert make_url(uri).get_backend_name() != "sqlite"
|
||||
|
||||
from dev.benchmarks.omnigent import seed as seed_mod
|
||||
|
||||
# --reseed so the count assertion holds against a DB that may already have a corpus.
|
||||
created = seed_mod.seed(uri, sessions=20, items_per_session=5, reseed=True)
|
||||
assert created == 20
|
||||
|
||||
conv = SqlAlchemyConversationStore(uri)
|
||||
listing = conv.list_conversations(limit=100, agent_name="bench-agent")
|
||||
assert len(listing.data) >= 20
|
||||
Reference in New Issue
Block a user