Skip to content

fix(oracle): make recall and mental-model history work on the Oracle backend - #1980

Merged
nicoloboschi merged 4 commits into
mainfrom
dk/oracle-backend-fixes
Jun 5, 2026
Merged

nicoloboschi merged 4 commits into
mainfrom
dk/oracle-backend-fixes

Conversation

@DK09876

@DK09876 DK09876 commented Jun 4, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Three code paths emit PostgreSQL-specific SQL (or rely on PG-only out-bind behavior) that breaks on the Oracle 23ai backend:

  1. Recall (ORA-03048) — retrieve_temporal_combined expands a batch of seed ids for multi-hop temporal-link spreading with FROM unnest($2::uuid[]) AS src(from_unit_id). Oracle has no unnest, so recall fails whenever the matched memories have temporal/causal links — i.e. most real queries.

  2. Mental-model history (ORA-00907) — update_mental_model trims the history array in SQL using jsonb_agg(... ORDER BY ...) over jsonb_array_elements(...) WITH ORDINALITY. This errors on Oracle, and because creating a mental model triggers a refresh that updates content, mental-model creation fails entirely on Oracle.

  3. RETURNING CLOBs (ORA-22835) — the Oracle RETURNING handler binds non-numeric/non-timestamp output columns as DB_TYPE_VARCHAR, which caps at 4000 bytes. Any CLOB-backed column returned via RETURNING raises ORA-22835: buffer too small for CLOB to CHAR conversion once it exceeds 4000 bytes. Surfaces as mental-model refresh failing when the synthesized content is large.

Fix

  1. Recall — guard the multi-hop spreading loop on the connection's backend_type. On backends without unnest we skip only the spread; temporal entry points and the semantic/keyword/graph retrievers are unaffected.

  2. Mental-model history — on Oracle, compute the trimmed history in Python (the current array is already fetched) and bind it as a single JSON value. The PostgreSQL SQL path is unchanged.

  3. RETURNING CLOBs — bind known CLOB columns (the JSON-as-CLOB set plus large-text columns content/text/context/structured_content/text_signals/search_vector) as DB_TYPE_CLOB, and read the LOB handle back to a string in _read_returning_values (the async pool yields AsyncLOB, whose read() is awaited). Non-CLOB columns are unchanged.

All three are instances of the dialect-asymmetry trap called out in CLAUDE.md.

Test plan

  • Oracle 23ai e2e smoke + HTTP integration: mental-model create / CRUD, full-lifecycle, retain (incl. tags), and document/memory CRUD pass.
  • Full Oracle integration suite: zero ORA-03048 and ORA-22835 occurrences; recall returns results on linked memories at all budgets.
  • Direct check: a 4277-byte CLOB now round-trips through UPDATE ... RETURNING (previously ORA-22835); RAW(16) ids still convert correctly.
  • PostgreSQL mental-model history unit tests (including max-entries trimming) still pass — the PG path is byte-identical.

🤖 Generated with Claude Code

DK09876 and others added 4 commits June 4, 2026 07:54
…backend

Two code paths emitted PostgreSQL-specific SQL that has no Oracle equivalent
and is not handled by the PG→Oracle query rewriter, so they raised hard
errors on the Oracle 23ai backend:

1. Recall — `retrieve_temporal_combined` expands a batch of seed ids for
   multi-hop temporal-link spreading with `FROM unnest($2::uuid[]) AS
   src(from_unit_id)`. Oracle has no `unnest`, so recall raised
   `ORA-03048` whenever the matched memories had temporal/causal links
   (the common case). Fix: guard the spreading loop on the connection's
   `backend_type`; on backends without `unnest` we skip only the multi-hop
   spread. The temporal entry points are still returned, and the
   semantic / keyword / graph retrievers are unaffected.

2. Mental-model history — `update_mental_model` trims the history array in
   SQL with `jsonb_agg(... ORDER BY ...)` over
   `jsonb_array_elements(...) WITH ORDINALITY`, which raised `ORA-00907`
   and made mental-model creation fail (the create path triggers a refresh
   that updates content). Fix: on Oracle, compute the trimmed history in
   Python (we already fetch the current array) and bind it as a single JSON
   value. The PostgreSQL SQL path is unchanged.

Both are instances of the dialect-asymmetry trap called out in CLAUDE.md.

Test plan:
- Oracle 23ai e2e smoke + HTTP integration: mental-model create/CRUD and
  full-lifecycle (previously failing with ORA-00907) now pass.
- Full Oracle integration suite shows zero ORA-03048 occurrences.
- PostgreSQL mental-model history unit tests (including max-entries
  trimming) still pass — the PG path is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Oracle backend's RETURNING handler bound every non-numeric, non-timestamp
output column as DB_TYPE_VARCHAR. VARCHAR out-binds cap at 4000 bytes, so any
CLOB-backed column returned via a RETURNING clause raised
`ORA-22835: buffer too small for CLOB to CHAR conversion` once its value
exceeded 4000 bytes. This surfaced as mental-model creation failing on Oracle:
the post-create refresh UPDATEs `content` (a CLOB) with `RETURNING content`,
and a sufficiently long synthesized snapshot (>4000 bytes) aborted the update.

Fix: bind known CLOB columns (the JSON-as-CLOB set plus the large-text columns
content/text/context/structured_content/text_signals/search_vector) as
DB_TYPE_CLOB in the RETURNING var setup, and read the LOB handle back to a
string in _read_returning_values (the async pool yields AsyncLOB, whose read()
is awaited). Non-CLOB columns are unchanged.

Verified against Oracle 23ai:
- A 4277-byte CLOB now round-trips through UPDATE ... RETURNING (previously
  ORA-22835); other columns (RAW(16) ids, etc.) still convert correctly.
- Mental-model create/refresh with large content succeeds.
- RETURNING-heavy Oracle integration tests (retain, tags, document/memory CRUD,
  http retain/recall, full lifecycle) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o unnest)

The temporal-recall entry-point selection was rewritten on main (#1983) to gate
candidates by embedding similarity within the window. That new query expanded the
fact_types with `FROM unnest($3::text[]) AS ft CROSS JOIN LATERAL (...)`, which has
no Oracle equivalent — so after merging main, Oracle recall would again fail with
ORA-03048 on any temporal query, in the entry-point query this time (the spreading
guard added here only covers the multi-hop spread).

Rebuild the entry-point query as a UNION ALL of one similarity-ranked,
window-filtered arm per fact_type with the fact_type inlined as a literal — the
same shape retrieve_semantic_bm25_combined already uses and which the Oracle
backend runs. The `<=>` operator and `LIMIT` are translated to VECTOR_DISTANCE and
FETCH FIRST on execute; only `unnest` was untranslatable, and it's now gone.

Behavior on PostgreSQL is unchanged (each arm still hits the per-(bank, fact_type)
vector index; selection + coverage logic is identical) — verified by the existing
temporal selection tests and the recall_perf temporal benchmark (temporal arm
~0.002s on the 680k dense bank). Oracle output verified through the real
_rewrite_pg_to_oracle translator: no unnest, valid VECTOR_DISTANCE + FETCH FIRST.
@nicoloboschi

Copy link
Copy Markdown
Collaborator

Merged main and extended the recall fix for the new temporal selection that just landed in #1983.

Why this was needed: #1983 rewrote the temporal entry-point selection to gate candidates by embedding similarity within the window, and that new query expanded fact_types with FROM unnest($3::text[]) AS ft CROSS JOIN LATERAL (...). unnest has no Oracle translation — so after #1983 merged, Oracle recall would again hit ORA-03048, this time in the entry-point query (the supports_unnest guard in this PR only covers the multi-hop spread).

Fix: rebuilt the entry-point query as a UNION ALL of one similarity-ranked, window-filtered arm per fact_type with the fact_type inlined as a literal — the same shape retrieve_semantic_bm25_combined already uses and which Oracle runs. <=>→VECTOR_DISTANCE and LIMIT→FETCH FIRST are handled by the existing translator; only unnest was the blocker and it's gone.

Verified:

  • PG behavior unchanged — temporal selection tests pass; recall_perf temporal arm ~0.002s on the 680k dense bank.
  • Oracle output checked through the real _rewrite_pg_to_oracle: no unnest, valid VECTOR_DISTANCE + FETCH FIRST, binds intact.

The Oracle e2e suite in CI here should now exercise the temporal recall path end-to-end.

@nicoloboschi
nicoloboschi merged commit 23710f4 into main Jun 5, 2026
74 of 80 checks passed
@nicoloboschi
nicoloboschi deleted the dk/oracle-backend-fixes branch June 5, 2026 09:29
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.

2 participants