268 lines
9.9 KiB
Python
268 lines
9.9 KiB
Python
"""
|
|
End-to-end regression for the conversation_items position race
|
|
(2026-04-30 user-reported ``UNIQUE constraint failed`` symptom).
|
|
|
|
This test runs a REAL Omnigent server subprocess against a REAL SQLite
|
|
database, then drives many concurrent ``append()`` calls
|
|
through the live ``SqlAlchemyConversationStore`` instance the
|
|
server's ``ConversationStore`` was wired with at startup. The
|
|
goal is to prove the position-race fix (lock escalation in
|
|
:meth:`SqlAlchemyConversationStore._lock_conversation`) holds
|
|
when:
|
|
|
|
1. The store is constructed in a separate process from the
|
|
test (matches the user's ``omnigent run`` setup
|
|
where the Omnigent server is a subprocess of the REPL).
|
|
2. The store's session factory + busy_timeout pragmas come
|
|
from the production code path
|
|
(:func:`omnigent.db.utils.make_managed_session_maker`),
|
|
not a test-only override.
|
|
|
|
The bug shape: the user's REPL session 2026-04-30 hit
|
|
``sqlite3.IntegrityError: UNIQUE constraint failed:
|
|
conversation_items.conversation_id, conversation_items.position``
|
|
when the agent loop's incremental tool-call persist raced the
|
|
steering inbox's auto-injection of idle-notification user
|
|
messages. Two concurrent appends both ran ``select(max(position))``
|
|
inside DEFERRED transactions (no write lock), both saw the same
|
|
``max_pos``, both inserted at ``max_pos + 1``, and the loser
|
|
crashed with the IntegrityError. The fix escalates the
|
|
transaction to RESERVED via a no-op UPDATE in
|
|
``_lock_conversation``, which forces SQLite to serialize the
|
|
two transactions on the busy_timeout instead of racing the
|
|
SELECT.
|
|
|
|
This e2e test:
|
|
|
|
- Spins up an Omnigent server subprocess (:fixture:`ap_server`).
|
|
- Imports the Omnigent server's actual ``SqlAlchemyConversationStore``
|
|
via the same ``get_or_create_engine`` cache the server uses,
|
|
pointing at the same DB file.
|
|
- Fires N concurrent appends from M threads in this test
|
|
process against the SHARED database file.
|
|
- Asserts no thread raised ``IntegrityError`` and final
|
|
positions are contiguous.
|
|
|
|
If the lock-escalation fix regresses, this test surfaces the
|
|
exact ``IntegrityError`` the user reported. The store-level
|
|
unit/integration tests in
|
|
``tests/stores/test_conversation_store.py`` cover the same
|
|
race in-process; this test is the cross-process regression
|
|
gate (catches the case where a future engine-cache change
|
|
makes the busy_timeout pragma not apply across all session
|
|
factories).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from omnigent.entities import MessageData, NewConversationItem
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _find_free_port() -> int:
|
|
"""Pick a free TCP port for the Omnigent subprocess to bind."""
|
|
s = socket.socket()
|
|
s.bind(("", 0))
|
|
port: int = s.getsockname()[1]
|
|
s.close()
|
|
return port
|
|
|
|
|
|
@pytest.fixture
|
|
def ap_server_with_shared_db() -> Iterator[tuple[str, str]]:
|
|
"""
|
|
Start a real Omnigent server subprocess and yield (base_url, db_uri).
|
|
|
|
The DB URI is the same one the test uses to construct its own
|
|
``SqlAlchemyConversationStore`` — so writes from the test
|
|
process and writes from the Omnigent server hit the same SQLite
|
|
file. Reproduces the cross-process write contention the
|
|
user's REPL session triggers (REPL process does HTTP →
|
|
Omnigent server process writes; test process directly writes via
|
|
the store, simulating a concurrent path).
|
|
|
|
:yields: ``(base_url, db_uri)`` — server URL and SQLite URI
|
|
pointing at the shared DB.
|
|
"""
|
|
port = _find_free_port()
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
tmp_root = Path(tempfile.mkdtemp(prefix="ap-append-race-e2e-"))
|
|
db_path = tmp_root / "ap.db"
|
|
db_uri = f"sqlite:///{db_path}"
|
|
artifact_dir = tmp_root / "artifacts"
|
|
artifact_dir.mkdir()
|
|
log_path = tmp_root / "server.log"
|
|
|
|
env = {
|
|
**os.environ,
|
|
"PYTHONPATH": (f"{_REPO_ROOT}{os.pathsep}{os.environ.get('PYTHONPATH', '')}"),
|
|
# Workflow's compaction layer constructs an LLM client
|
|
# at startup; stub satisfies the env check.
|
|
"OPENAI_API_KEY": "stub-not-used",
|
|
}
|
|
for var in ("DATABRICKS_TOKEN", "ANTHROPIC_API_KEY", "CODEX", "CLAUDE_CODE"):
|
|
env.pop(var, None)
|
|
|
|
log_handle = open(log_path, "w") # noqa: SIM115 — subprocess holds the FD
|
|
proc = subprocess.Popen(
|
|
[
|
|
str(_REPO_ROOT / ".venv" / "bin" / "python"),
|
|
"-m",
|
|
"omnigent.cli",
|
|
"server",
|
|
"--port",
|
|
str(port),
|
|
"--database-uri",
|
|
db_uri,
|
|
"--artifact-location",
|
|
str(artifact_dir),
|
|
],
|
|
env=env,
|
|
cwd=str(_REPO_ROOT),
|
|
stdout=log_handle,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
|
|
try:
|
|
# Wait for /health.
|
|
deadline = time.monotonic() + 30.0
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
resp = httpx.get(f"{base_url}/health", timeout=2.0)
|
|
if resp.status_code == 200:
|
|
break
|
|
except (httpx.ConnectError, httpx.ReadError):
|
|
pass
|
|
time.sleep(0.2)
|
|
else:
|
|
log_handle.close()
|
|
raise RuntimeError(f"AP server failed to start. log: {log_path}")
|
|
yield (base_url, db_uri)
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5.0)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
log_handle.close()
|
|
|
|
|
|
def test_concurrent_appends_against_live_omnigent_server_db_no_collision(
|
|
ap_server_with_shared_db: tuple[str, str],
|
|
) -> None:
|
|
"""
|
|
With a live Omnigent server running, fire N concurrent appends
|
|
from this test process against the SAME SQLite DB file the
|
|
server is using. No append must raise IntegrityError; final
|
|
positions must be contiguous.
|
|
|
|
What this proves and what a failure means:
|
|
|
|
- The lock-escalation fix in
|
|
:meth:`SqlAlchemyConversationStore._lock_conversation`
|
|
works against a database file shared with another process
|
|
(the Omnigent server). On revert, the test surfaces the user's
|
|
exact ``UNIQUE constraint failed`` error.
|
|
- The production engine-cache + session-factory wiring
|
|
(``get_or_create_engine`` + ``make_managed_session_maker``)
|
|
applies the ``busy_timeout=20000`` PRAGMA to every
|
|
session, so the second writer blocks rather than failing
|
|
fast on SQLITE_BUSY.
|
|
|
|
Note: this test does NOT exercise the Omnigent route layer (the
|
|
HTTP append endpoint doesn't exist; appends go through
|
|
workflow execution which needs an LLM). Coverage of the
|
|
HTTP path is captured by the workflow integration tests
|
|
in ``tests/runtime/test_workflow.py`` and the live REPL
|
|
re-runs documented in CLAUDE.md's "Mandatory REPL
|
|
Verification" section.
|
|
"""
|
|
_, db_uri = ap_server_with_shared_db
|
|
|
|
# Create our own store handle pointing at the SAME database
|
|
# file. ``get_or_create_engine`` caches engines per URI, so
|
|
# this gets a separate engine in this process (the Omnigent server
|
|
# has its own engine in its process); both share the SQLite
|
|
# file via filesystem-level concurrency control.
|
|
conv_store = SqlAlchemyConversationStore(db_uri)
|
|
conv = conv_store.create_conversation()
|
|
|
|
threads_count = 6
|
|
items_per_thread = 8
|
|
errors: list[tuple[int, Exception]] = []
|
|
errors_lock = threading.Lock()
|
|
|
|
def _append_worker(thread_idx: int) -> None:
|
|
try:
|
|
for i in range(items_per_thread):
|
|
conv_store.append(
|
|
conv.id,
|
|
[
|
|
NewConversationItem(
|
|
type="message",
|
|
response_id=f"resp_e2e_t{thread_idx}",
|
|
data=MessageData(
|
|
role="user",
|
|
content=[
|
|
{
|
|
"type": "input_text",
|
|
"text": f"e2e-t{thread_idx}-i{i}",
|
|
}
|
|
],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
except Exception as exc:
|
|
with errors_lock:
|
|
errors.append((thread_idx, exc))
|
|
|
|
threads = [threading.Thread(target=_append_worker, args=(i,)) for i in range(threads_count)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=60.0)
|
|
|
|
assert errors == [], (
|
|
f"Concurrent appends against the live Omnigent server's SQLite "
|
|
f"DB raised {len(errors)} error(s); first: {errors[0]!r}. "
|
|
f"The cross-process / cross-engine lock escalation "
|
|
f"regressed — the user-reported 2026-04-30 IntegrityError "
|
|
f"on conversation_items.conversation_id+position is back."
|
|
)
|
|
|
|
expected_count = threads_count * items_per_thread
|
|
items = conv_store.list_items(conv.id, limit=1000).data
|
|
assert len(items) == expected_count, (
|
|
f"expected {expected_count} items; got {len(items)} — some "
|
|
f"appends silently dropped despite not raising."
|
|
)
|
|
# Position uniqueness is enforced by the SQL UNIQUE index on
|
|
# (conversation_id, position). Any race that bypassed the
|
|
# lock escalation and produced duplicate positions would
|
|
# have raised IntegrityError above. The count + ID
|
|
# distinctness check is sufficient — and uses only the
|
|
# public store API, no private session access.
|
|
item_ids = {item.id for item in items}
|
|
assert len(item_ids) == expected_count, (
|
|
f"expected {expected_count} distinct item IDs; got "
|
|
f"{len(item_ids)}. Duplicate IDs would mean an item was "
|
|
f"persisted twice across the cross-process boundary."
|
|
)
|