Skip to content

Deduplicate call-log prompt text into a hash-keyed prompts table - #22

Merged
qinxuye merged 4 commits into
mainfrom
dedupe-prompt-storage
Jul 9, 2026
Merged

Deduplicate call-log prompt text into a hash-keyed prompts table#22
qinxuye merged 4 commits into
mainfrom
dedupe-prompt-storage

Conversation

@qinxuye

@qinxuye qinxuye commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Why

xagent re-requests routing for the same prompt many times within one task — a real deployment log had 1088 calls over only 161 distinct prompts (one prompt repeated 72×, at 4–10s intervals). Each call stored the full prompt text again.

What

  • New prompts table: sha256 (unique, indexed) + text. calls.prompt is replaced by a prompt_id FK (indexed).
  • record() looks the prompt up by content hash and inserts only if new; the insert is savepoint-protected so a concurrent writer inserting the same hash cannot fail the transaction.
  • recent() joins the text back (lazy="joined"), so the history API/UI response shape is unchanged.
  • delete() garbage-collects the prompt row once no call references it — the log holds user prompts, so orphaned text must not outlive its last call.
  • Migration 0004 backfills existing DBs (dedup existing text, swap the column, batch mode for SQLite) with a full downgrade path. Legacy pre-Alembic DBs stamp correctly via the existing _SCHEMA_CHECKPOINTS mechanism (prompt_id → 0004).

Verified

  • 69 tests pass (new coverage: dedup storage, delete GC, legacy-DB backfill with duplicate rows).
  • Migration exercised on a copy of the real 1088-row deployment DB: all calls preserved, 161 prompts, 0 orphaned references, integrity_check ok; then applied for real via server restart (auto-migrate) and the live /api/history returns full text.
  • WHERE sha256=? confirmed to use the unique index (covering-index search).
  • xrouter-llm-enterprise is unaffected (never touches CallStore); its 23 tests pass against this change.

Note for operators

Old code cannot read a migrated DB (the prompt column is gone) — rolling back the package requires alembic downgrade or a backup. Multi-replica Postgres/MySQL deployments should migrate on a single instance first (run_migrations takes no cross-process lock).

xagent re-routes the same prompt many times within one task (a real log
had 1088 calls over only 161 distinct prompts, one prompt repeated 72x),
so identical prompt text was stored once per call.

- New prompts table (sha256 unique + text); calls.prompt becomes a
  prompt_id FK. recent() joins the text back, so the history API shape
  is unchanged.
- record() upserts the prompt by content hash (savepoint-protected
  against concurrent inserts of the same hash).
- delete() garbage-collects the prompt row once no call references it,
  so user prompt text never outlives its last call.
- Migration 0004 backfills existing databases (dedup + column swap) and
  supports downgrade; legacy pre-Alembic databases stamp through the
  existing checkpoint mechanism.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new prompts table to deduplicate prompt texts from the calls table, including an Alembic migration to backfill existing data, updated SQLAlchemy models, and garbage collection of orphaned prompts upon call deletion. The review feedback highlights critical concurrency and database compatibility issues: a potential migration failure on case-insensitive databases due to subquery cardinality, and race conditions between concurrent prompt deletions and insertions. To address these, the reviewer suggests using MIN(p.id) in the migration subquery and implementing with_for_update locks during prompt retrieval and garbage collection.

Comment thread src/xrouter_llm/migrations/versions/0004_dedupe_prompt_text.py Outdated
Comment thread src/xrouter_llm/store.py
Comment thread src/xrouter_llm/store.py Outdated
Comment thread src/xrouter_llm/store.py
qinxuye added 2 commits July 9, 2026 12:02
An unspaced 60-char token (e.g. a URL prompt) has no break opportunity,
so it overflowed the 260px prompt cell. Apply word-break: break-all to
the short view, matching the expanded .full-prompt detail view.
…enforcement

- delete() locks the prompt row (FOR UPDATE) and uses a locking existence
  read before GC, so two transactions deleting the last two calls of one
  prompt cannot both skip GC and leak the text, and a concurrent record()
  cannot lose its prompt row mid-transaction.
- _get_or_create_prompt() locks the looked-up row for the same reason and
  so the post-conflict re-read sees the winner's row under REPEATABLE READ.
- On SQLite FOR UPDATE is a no-op: enable PRAGMA foreign_keys so a dangling
  prompt_id raises instead of being accepted, and retry record() once when
  a concurrent GC invalidates the prompt between lookup and insert.
- Migration backfill now dedupes by exact content hash in Python instead of
  SQL DISTINCT / text-equality joins, which under case-insensitive
  collations (MySQL default) would merge prompts differing only in casing
  and rewrite the stored text of some calls.
- Regression tests: concurrent-delete GC (row-locking backends) and
  case-variant backfill.
@qinxuye

qinxuye commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

Comment thread src/xrouter_llm/store.py
delete() now takes the prompt row lock BEFORE deleting the call row, so
every delete()/record() for one prompt serializes on a single lock before
acquiring per-call row locks. With the previous order (call row first,
prompt lock second), two transactions deleting the last two calls of one
prompt deadlocked: T1 held the prompt lock while its locking reference
check waited on T2's deleted-but-uncommitted call row, and T2 waited on
the prompt lock.

The regression test now runs the real reference check concurrently with
the queued delete — verified against Postgres 16: the old order fails
with a detected deadlock, the new order passes.
@qinxuye
qinxuye merged commit d0e948a into main Jul 9, 2026
4 checks passed
@qinxuye
qinxuye deleted the dedupe-prompt-storage branch July 9, 2026 05:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant