Files
2026-07-13 13:12:33 +08:00

373 lines
12 KiB
Python

"""Opt-in gateway/WebSocket compaction regression tests."""
from __future__ import annotations
import asyncio
import os
import socket
import sqlite3
import subprocess
import sys
import textwrap
import time
from pathlib import Path
from typing import Any
import httpx
import pytest
from opensquilla.cli.gateway_client import GatewayClient
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _wait_for_health(port: int, server: subprocess.Popen[str]) -> None:
url = f"http://127.0.0.1:{port}/health"
deadline = time.monotonic() + 20.0
last_error = ""
while time.monotonic() < deadline:
if server.poll() is not None:
stdout = server.stdout.read() if server.stdout else ""
stderr = server.stderr.read() if server.stderr else ""
raise AssertionError(
f"gateway exited early code={server.returncode}\nstdout={stdout}\nstderr={stderr}"
)
try:
response = httpx.get(url, timeout=1.0)
if response.status_code == 200 and response.json().get("ok") is True:
return
except Exception as exc: # noqa: BLE001 - surfaced on timeout.
last_error = str(exc)
time.sleep(0.1)
raise AssertionError(f"gateway did not become healthy: {last_error}")
def _stop_process(server: subprocess.Popen[str]) -> None:
if server.poll() is not None:
return
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
server.wait(timeout=10)
def _kill_process(server: subprocess.Popen[str]) -> None:
if server.poll() is not None:
return
server.kill()
server.wait(timeout=10)
def _write_slow_compaction_server(
path: Path,
*,
port: int,
db_path: Path,
session_key: str,
) -> None:
path.write_text(
textwrap.dedent(
f"""
import asyncio
import os
from opensquilla.gateway.boot import start_gateway_server
from opensquilla.gateway.config import AuthConfig, GatewayConfig
from opensquilla.gateway.websocket import SubscriptionManager
from opensquilla.session.manager import SessionManager
from opensquilla.session.storage import SessionStorage
SESSION_KEY = {session_key!r}
class SlowCompactionSessionManager(SessionManager):
async def compact_with_result(self, *args, **kwargs):
await asyncio.sleep(1.2)
return await super().compact_with_result(*args, **kwargs)
async def seed(manager):
existing = await manager.get_session(SESSION_KEY)
if existing is not None:
return
node = await manager.create(
session_key=SESSION_KEY,
agent_id="main",
display_name="slow compaction e2e",
)
for idx in range(11):
await manager.append_message(
node.session_key,
role="user" if idx % 2 == 0 else "assistant",
content=("seed transcript block %02d " % idx) + ("alpha beta gamma " * 80),
token_count=320,
)
async def main():
os.makedirs(os.environ["OPENSQUILLA_STATE_DIR"], exist_ok=True)
storage = SessionStorage({str(db_path)!r})
await storage.connect()
manager = SlowCompactionSessionManager(storage, inject_time_prefix=False)
await seed(manager)
config = GatewayConfig(
host="127.0.0.1",
port={port},
auth=AuthConfig(mode="none"),
)
config.state_dir = os.environ["OPENSQUILLA_STATE_DIR"]
await start_gateway_server(
config=config,
session_manager=manager,
subscription_manager=SubscriptionManager(),
run=True,
)
await asyncio.Event().wait()
asyncio.run(main())
"""
),
encoding="utf-8",
)
async def _manual_compact_over_websocket(port: int, session_key: str) -> dict[str, Any]:
client = GatewayClient()
await client.connect(f"ws://127.0.0.1:{port}/ws")
try:
await client.call("sessions.subscribe")
await client.call("sessions.messages.subscribe", {"key": session_key})
compact_task = asyncio.create_task(
client.call(
"sessions.contextCompact",
{"key": session_key, "contextWindowTokens": 1000},
)
)
statuses: list[str] = []
all_statuses: list[str] = []
while True:
if compact_task.done() and "completed" in all_statuses:
break
frame = await asyncio.wait_for(client._recv_queue.get(), timeout=45.0) # noqa: SLF001
if frame.get("event") != "session.event.compaction":
continue
payload = frame.get("payload") or {}
status = str(payload.get("status") or "")
all_statuses.append(status)
if status:
statuses.append(status)
if status == "started":
assert compact_task.done() is False
if status in {"completed", "failed", "skipped", "cancelled"}:
break
result = await asyncio.wait_for(compact_task, timeout=15.0)
return {
"statuses": statuses,
"all_statuses": all_statuses,
"result": result,
}
finally:
await client.close()
async def _start_manual_compact_and_wait_for_started(
port: int, session_key: str
) -> tuple[GatewayClient, asyncio.Task[Any], dict[str, Any]]:
client = GatewayClient()
await client.connect(f"ws://127.0.0.1:{port}/ws")
await client.call("sessions.subscribe")
await client.call("sessions.messages.subscribe", {"key": session_key})
compact_task = asyncio.create_task(
client.call(
"sessions.contextCompact",
{"key": session_key, "contextWindowTokens": 1000},
)
)
while True:
frame = await asyncio.wait_for(client._recv_queue.get(), timeout=15.0) # noqa: SLF001
if frame.get("event") != "session.event.compaction":
continue
payload = frame.get("payload") or {}
if payload.get("status") == "started":
assert compact_task.done() is False
return client, compact_task, payload
def _read_compaction_db_state(db_path: Path) -> dict[str, int]:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
session = conn.execute(
"select session_id, compaction_count from sessions limit 1"
).fetchone()
assert session is not None
session_id = session["session_id"]
summary_count = conn.execute(
"select count(*) from session_summaries where session_id = ?",
(session_id,),
).fetchone()[0]
entry_count = conn.execute(
"select count(*) from transcript_entries where session_id = ?",
(session_id,),
).fetchone()[0]
running_tasks = conn.execute(
"select count(*) from agent_tasks where status in ('queued', 'running')"
).fetchone()[0]
return {
"compaction_count": int(session["compaction_count"] or 0),
"summary_count": int(summary_count),
"entry_count": int(entry_count),
"running_tasks": int(running_tasks),
}
def _start_slow_compaction_gateway(
tmp_path: Path,
*,
port: int,
db_path: Path,
session_key: str,
env: dict[str, str],
) -> subprocess.Popen[str]:
server_script = tmp_path / "slow_compaction_gateway.py"
_write_slow_compaction_server(
server_script,
port=port,
db_path=db_path,
session_key=session_key,
)
return subprocess.Popen(
[sys.executable, str(server_script)],
cwd=Path.cwd(),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
@pytest.mark.asyncio
async def test_gateway_websocket_slow_manual_compaction_rewrites_db(
tmp_path: Path,
) -> None:
if os.environ.get("OPENSQUILLA_GATEWAY_COMPACTION_E2E") != "1":
pytest.skip("set OPENSQUILLA_GATEWAY_COMPACTION_E2E=1 to run compaction e2e")
port = _free_port()
session_key = "agent:main:webchat:slowcompact"
state_dir = tmp_path / "state"
log_dir = tmp_path / "logs"
db_path = state_dir / "sessions.db"
env = os.environ.copy()
env["OPENSQUILLA_STATE_DIR"] = str(state_dir)
env["OPENSQUILLA_LOG_DIR"] = str(log_dir)
server = _start_slow_compaction_gateway(
tmp_path,
port=port,
db_path=db_path,
session_key=session_key,
env=env,
)
try:
_wait_for_health(port, server)
observed = await asyncio.wait_for(
_manual_compact_over_websocket(port, session_key),
timeout=90.0,
)
finally:
_stop_process(server)
result = observed["result"]
db_state = _read_compaction_db_state(db_path)
assert observed["statuses"] == ["started", "observed", "observed", "completed"]
assert result["compacted"] is True
assert result["tokens_before"] == 3520
assert result["tokens_after"] < result["tokens_before"]
assert db_state["compaction_count"] == 1
assert db_state["summary_count"] == 1
assert db_state["entry_count"] == result["kept_count"]
assert db_state["running_tasks"] == 0
@pytest.mark.asyncio
async def test_gateway_restart_during_slow_manual_compaction_leaves_db_recoverable(
tmp_path: Path,
) -> None:
if os.environ.get("OPENSQUILLA_GATEWAY_COMPACTION_E2E") != "1":
pytest.skip("set OPENSQUILLA_GATEWAY_COMPACTION_E2E=1 to run compaction e2e")
port = _free_port()
session_key = "agent:main:webchat:slowcompact"
state_dir = tmp_path / "state"
log_dir = tmp_path / "logs"
db_path = state_dir / "sessions.db"
env = os.environ.copy()
env["OPENSQUILLA_STATE_DIR"] = str(state_dir)
env["OPENSQUILLA_LOG_DIR"] = str(log_dir)
server = _start_slow_compaction_gateway(
tmp_path,
port=port,
db_path=db_path,
session_key=session_key,
env=env,
)
client: GatewayClient | None = None
compact_task: asyncio.Task[Any] | None = None
try:
_wait_for_health(port, server)
client, compact_task, _payload = await _start_manual_compact_and_wait_for_started(
port,
session_key,
)
_kill_process(server)
with pytest.raises(Exception):
await asyncio.wait_for(compact_task, timeout=5.0)
finally:
if client is not None:
await client.close()
_stop_process(server)
interrupted_state = _read_compaction_db_state(db_path)
assert interrupted_state == {
"compaction_count": 0,
"summary_count": 0,
"entry_count": 11,
"running_tasks": 0,
}
restarted = _start_slow_compaction_gateway(
tmp_path,
port=port,
db_path=db_path,
session_key=session_key,
env=env,
)
try:
_wait_for_health(port, restarted)
observed = await asyncio.wait_for(
_manual_compact_over_websocket(port, session_key),
timeout=90.0,
)
finally:
_stop_process(restarted)
assert observed["statuses"] == ["started", "observed", "observed", "completed"]
assert observed["result"]["compacted"] is True
recovered_state = _read_compaction_db_state(db_path)
assert recovered_state["compaction_count"] == 1
assert recovered_state["summary_count"] == 1
assert recovered_state["running_tasks"] == 0