Skip to content

fix: preserve repeated public IDs in response history - #388

Closed
RainieLLM wants to merge 1 commit into
vllm-project:mainfrom
RainieLLM:codex/fix-response-history-item-ids
Closed

RainieLLM wants to merge 1 commit into
vllm-project:mainfrom
RainieLLM:codex/fix-response-history-item-ids

Conversation

@RainieLLM

Copy link
Copy Markdown

Summary

A request with store: true can fail with 500 when a client sends a previous response's output back as input. The insert reuses public item IDs as database primary keys, so IDs already stored cause a conflict.

This change stores each response history occurrence under a fresh row ID and keeps the public ID in a separate nullable column. Response history records the row IDs in input order, so repeated public IDs keep their content and position. Existing rows still use their current IDs and references.

Fixes #385.

Test Plan

  • cargo test: 1,782 tests passed.
  • PostgreSQL 17: all 15 schema, storage, and tenant-isolation tests passed, including the new repeated-ID test across SQL batches.
  • cargo clippy --all-targets -- -D warnings: passed.
  • pre-commit run --all-files: passed.

The new tests cover resubmitted output through ExecuteRequest with existing recorded responses, repeated IDs, ordered history, continuation, transaction rollback, and an ID already owned by another tenant. The two SQLite regressions fail with a primary-key conflict on the old insert path.

Signed-off-by: RainieLLM <rainie97cs@gmail.com>

@maralbahari maralbahari left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the fix. I have have found couple regression in conversation API in this PR.

let rows = sqlx::query_as::<_, ConversationSnapshotRow>(
"SELECT conversations.latest_response_id, conversations.revision, \
items.id AS item_id, items.reference_id AS item_reference_id, \
items.id AS item_id, items.reference_id AS item_reference_id, items.public_id AS item_public_id, \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This changes response items to use a fresh database row ID, but create_in_tx_with_next_conversation_seq still resolves existing items through source.id = incoming.id. It therefore no longer finds these items by their public ID.

I reproduced two regressions on a fresh SQLite database:

  • Adding a stored response item to a conversation with replacement text stores that replacement instead of preserving the original content.
  • Adding that ID twice in one request fails with UNIQUE constraint failed: items.id.

Both cases pass on the parent revision. Could we update conversation item resolution to handle public_id and add coverage for this Responses-to-Conversations flow?

@awesome-pro

Copy link
Copy Markdown
Contributor

i think the mechanism here still looks right on current main, but the branch no longer applies cleanly.

  1. rebase needs two more literals: storage/models/item.rs:592 and executor/gateway.rs:1615 construct Item { .. } since feat: support code interpreter as gateway execution tool #368, so cargo test and clippy --all-targets fail with E0063 as is.
  2. the review point is still valid. create_in_tx_with_next_conversation_seq (storage/models/item.rs:284-298) resolves by source.id = incoming.id and dedupes on COALESCE(existing.reference_id, existing.id). once history rows are keyed by fresh item_… ids, adding a stored response item to a conversation stores the supplied replacement text instead of the original content, and a repeated public id in one request still hits UNIQUE constraint failed: items.id. COALESCE(source.public_id, source.reference_id, source.id) = incoming.id in both places, plus an index on items(public_id), covers it.
  3. migration numbering needs one coordination step: this pr adds 0007_response_item_public_ids.sql while open feat: establish provider-aware reasoning replay foundation #350 adds 0007_reasoning_provenance.sql, and both bump REQUIRED_POSTGRES_SCHEMA_COLUMN_COUNT in storage/schema.rs. whichever lands second should renumber to 0008 and set the count to 23.

@RainieLLM if u want i can rebase this, apply the resolution fix and add the two regression tests if that helps.

@maralbahari

Copy link
Copy Markdown
Collaborator

@RainieLLM thanks for the PR closing this in favor of #455

maralbahari added a commit that referenced this pull request Oct 20, 2026
## summary

fixes #385.

stored response history used each item's public id as the `items.id`
primary key. a client that sent a previous response's `output` back as
`input` with `store: true` (the default) hit `UNIQUE constraint failed:
items.id` and a 500, even though the request is valid:

Persistence(Storage(Database(Database(SqliteError { code: 1555, message:
"UNIQUE constraint failed: items.id" }))))

`item::create_in_tx` now gives each response-history occurrence a fresh
`item_…` row id and keeps the public id in a new nullable
`items.public_id` column. `Item::public_id()` still returns the public
id, so clients see stable ids, and repeated occurrences keep every
position. conversation inserts keep their membership checks, sequence
allocation, and reference resolution in the same function.

conversation inserts, cursors, retrieval and detach resolve an item by
public identity (`COALESCE(public_id, reference_id, id)`). the lookup is
indexed on that expression with the tenant, because a plain `public_id`
index is not usable by the resolved predicate. the join resolves one
source row per incoming item first (a `source_rows` CTE): several rows
can share a public identity, and joining on the identity alone fans out
into duplicate row ids, which then fails on `items.id`.

this supersedes #388, which used the same mechanism but no longer
compiles on main and left the conversation resolution untouched. thanks
to @RainieLLM for the original approach.

## test plan

- `resubmitted_item_history_test` replays the recorded two-turn `OpenAI`
responses (`text_only/responses/`, streaming and non-streaming). it
asserts the upstream request the second turn sends, then the rehydrated
order, roles, content, public ids, and distinct row ids. the
resubmission case is constructed from the same recording and keeps every
submitted occurrence.
- `stateful_conversation_integration` asserts the same for the recorded
two-turn conversation path (`text_only/conversation/`, streaming and
non-streaming): rehydrated history, public ids, row keys, and the
upstream history.
- `conversation_storage_regressions` covers repeated occurrences of one
public id, cross-tenant non-reuse, and batch rollback.
- `conversations_api_cassette_test` covers the conversation path,
including
`edge_replay::gateway_matches_all_openai_edge_case_status_codes_and_bodies`,
which posts one stored public id twice and caught the fan-out.
- reverting the resolution predicate reproduces `UNIQUE constraint
failed: items.id`, and removing the single-source reduction reproduces
`UNIQUE constraint failed: items.conversation_id, items.seq`.
- index check on sqlite with 100k existing items in one tenant:
inserting 100 items takes 1.0 ms with the expression index and 640 ms
without it. the plan changes from `SEARCH source USING INDEX
idx_items_tenant_id` to `SEARCH source USING INDEX
idx_items_public_identity (tenant_id=? AND <expr>=?)`.
- also ran `cargo fmt -- --check`, `cargo clippy -p agentic-server-core
--all-targets -- -D warnings`, `cargo test --workspace`, and the
production file-size check.

## notes

- the migration is `0007_response_item_public_ids.sql`. open #350 also
proposes a `0007`; whichever merges second renumbers to `0008`.
- consolidating the insert into `item.rs` crosses the 500-line
production cap, so `.rust-file-sizes.json` records a reviewed exception
(510) with the reason.
- existing rows keep the public id as their row id and a null
`public_id`; `Item::public_id()` falls back to `reference_id` and then
`id`, so no backfill is needed.
- postgres was not exercised locally (no server available). the
expression index uses standard `CREATE INDEX` syntax, and the ci
`postgres` job applies the migration and runs the storage tests.

---------

Signed-off-by: Abhinandan <abhinandanverma551@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Co-authored-by: maral <maralbahari.98@gmail.com>
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.

Resubmitting stored output items with store: true fails with 500 after #370

3 participants