Add dry-run sqlite retention prune for tool/trace bloat.

Keeps recent days of chat tool messages, tool_log, traces, and sent outbound; admin API and CLI support apply + optional VACUUM.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-11 00:22:26 +08:00
parent cc9a516cb4
commit 48e5872045
4 changed files with 619 additions and 0 deletions

View file

@ -0,0 +1,141 @@
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from svc.persistence.sqlite_retention import prune_sqlite_retention
from svc.persistence.sqlite_store import SqliteStore
class SqliteRetentionTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "ret.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
self.store = SqliteStore(str(self.db))
self.sess = self.store.create_session("retention-test")
self.session_id = str(self.sess.id)
self.now = datetime(2026, 8, 10, 12, 0, 0, tzinfo=timezone.utc)
self.old = (self.now - timedelta(days=40)).isoformat()
self.fresh = (self.now - timedelta(days=2)).isoformat()
def tearDown(self) -> None:
self._tmp.cleanup()
def _seed(self) -> None:
with self.store._connect() as conn:
conn.execute(
"INSERT INTO chat_message(session_id, role, content, timestamp) VALUES (?,?,?,?)",
(self.session_id, "tool", "OLD_TOOL_PAYLOAD" * 100, self.old),
)
conn.execute(
"INSERT INTO chat_message(session_id, role, content, timestamp) VALUES (?,?,?,?)",
(self.session_id, "tool", "FRESH_TOOL", self.fresh),
)
conn.execute(
"INSERT INTO chat_message(session_id, role, content, timestamp) VALUES (?,?,?,?)",
(self.session_id, "assistant", "keep me", self.old),
)
conn.execute(
"INSERT INTO tool_log(session_id, tool_name, specialist, args, result, timestamp) "
"VALUES (?,?,?,?,?,?)",
(self.session_id, "t1", "", "{}", '"OLD_RESULT"', self.old),
)
conn.execute(
"INSERT INTO tool_log(session_id, tool_name, specialist, args, result, timestamp) "
"VALUES (?,?,?,?,?,?)",
(self.session_id, "t2", "", "{}", '"FRESH"', self.fresh),
)
conn.execute(
"INSERT INTO trace_event(session_id, trace_id, span_id, parent_span_id, event_type, payload, timestamp) "
"VALUES (?,?,?,?,?,?,?)",
(self.session_id, "tr1", "sp1", "", "x", "{}", self.old),
)
conn.execute(
"INSERT INTO channel_outbound_message"
"(id, tenant_id, channel, account_id, chat_id, text, status, source, created_at, error) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
("ob-old", "", "whatsapp", "wa", "c", "hi", "sent", "", self.old, ""),
)
conn.execute(
"INSERT INTO channel_outbound_message"
"(id, tenant_id, channel, account_id, chat_id, text, status, source, created_at, error) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
("ob-pending", "", "whatsapp", "wa", "c", "hi", "pending", "", self.old, ""),
)
def test_dry_run_then_apply(self) -> None:
self._seed()
plan = prune_sqlite_retention(self.store, keep_days=30, dry_run=True, now=self.now)
self.assertTrue(plan.get("ok"))
self.assertTrue(plan.get("dry_run"))
self.assertGreaterEqual(int(plan.get("total_rows") or 0), 3)
self.assertGreater(int((plan.get("targets") or {}).get("chat_message_tool", {}).get("rows") or 0), 0)
# Nothing deleted yet.
with self.store._connect() as conn:
n_tool = conn.execute(
"SELECT COUNT(*) FROM chat_message WHERE role='tool'"
).fetchone()[0]
self.assertEqual(int(n_tool), 2)
out = prune_sqlite_retention(
self.store,
keep_days=30,
dry_run=False,
vacuum=False,
now=self.now,
)
self.assertTrue(out.get("ok"))
self.assertFalse(out.get("dry_run"))
self.assertGreaterEqual(int(out.get("deleted_rows") or 0), 3)
with self.store._connect() as conn:
roles = [
r[0]
for r in conn.execute("SELECT role FROM chat_message ORDER BY id").fetchall()
]
self.assertIn("assistant", roles)
self.assertIn("tool", roles) # fresh tool kept
self.assertEqual(roles.count("tool"), 1)
tool_logs = conn.execute("SELECT COUNT(*) FROM tool_log").fetchone()[0]
self.assertEqual(int(tool_logs), 1)
traces = conn.execute("SELECT COUNT(*) FROM trace_event").fetchone()[0]
self.assertEqual(int(traces), 0)
outbound = {
r[0]: r[1]
for r in conn.execute(
"SELECT id, status FROM channel_outbound_message"
).fetchall()
}
self.assertNotIn("ob-old", outbound)
self.assertIn("ob-pending", outbound)
def test_vacuum_shrinks_after_large_delete(self) -> None:
self._seed()
# Inflate then delete + vacuum.
with self.store._connect() as conn:
conn.execute(
"INSERT INTO chat_message(session_id, role, content, timestamp) VALUES (?,?,?,?)",
(self.session_id, "tool", "X" * 200_000, self.old),
)
before = self.db.stat().st_size
out = prune_sqlite_retention(
self.store,
keep_days=30,
dry_run=False,
vacuum=True,
now=self.now,
)
self.assertTrue(out.get("ok"))
self.assertTrue((out.get("vacuum") or {}).get("ok"))
after = self.db.stat().st_size
self.assertLess(after, before)
if __name__ == "__main__":
unittest.main()