Skip to content

fix: storage answers for the database it has (B28) - #2033

Merged
eldad-caura-ai merged 1 commit into
mainfrom
b28-upgrade-readiness
Oct 11, 2026
Merged

eldad-caura-ai merged 1 commit into
mainfrom
b28-upgrade-readiness

Conversation

@eldad-caura-ai

@eldad-caura-ai eldad-caura-ai commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Why

The four open findings from audit batch B28 (database upgrade and readiness). All are in the storage service.

  • L-37: a request body that is not valid UTF-8 fails with UnicodeDecodeError, which the app-wide 422 handler for JSONDecodeError does not catch. Every router that parses its body inline answers 500, which core-api turns into a retryable 503.
  • L-139: a reader configured from the README (CORE_STORAGE_ROLE=reader, READ_DATABASE_URL set) sends only its get_read_session call sites to the read database. The rest, about two thirds, open the writer engine, still on DATABASE_URL. Shutdown disposes only the writer engine, and on a reader nothing.
  • L-145: preflight_012 promises exit 0 for a database already past 012 but decides on the row count alone, so a re-embedded database past 012 is reported destructive (exit 1).
  • L-97 (the remaining half; the JSON types were fixed in fix(models): declare the JSON columns migration 001 creates as json #1794): 17 columns migration 001 created nullable are declared NOT NULL by their models, so autogenerate proposes SET NOT NULL and create_all test schemas refuse rows production stores.

What changes

  • L-37: an app-wide UnicodeDecodeError handler answers 422, as the malformed-JSON handler does, but only for a decode raised inside Request.json, that is, while parsing the body. Any other decode failure is re-raised and stays a 500. The two routers that caught it locally (tenant_suppression, preview) now rely on it.
  • L-139: on a reader given READ_DATABASE_URL, get_engine() returns the read engine, so every session opens there and the reader keeps one pool. This is what config.py already describes and what the SaaS reader gets by setting DATABASE_URL to the read pool; a reader without READ_DATABASE_URL, and the writer and hybrid roles, are unchanged. Shutdown calls a new dispose_engines(), which closes every engine the process built, on any role. The README says what READ_DATABASE_URL does on each role.
  • L-145: preflight_012 places the head on the migration chain before counting. At 012 or later it reports 012 applied, counts nothing and exits 0; the JSON report gains migration_applied. A head before 012, or one this checkout's chain does not know, is counted as before.
  • L-97: the models declare nullable=True on the 17 columns migration 001 created nullable, as fix(models): declare the JSON columns migration 001 creates as json #1794 did for their JSON types. Annotations are unchanged. A new compare_metadata check fails on any modify_nullable diff. NON_NULLABLE_UPDATE_FIELDS keeps weight, so a PATCH still refuses a null weight; its drift test now says why. The database is not changed. Eldad decided against a NOT NULL migration; a follow-up PR will stop storage's bulk insert writing an explicit NULL weight or created_at, the one path that stores a NULL today.
  • test_schema_drift_report's lifespan test now checks that shutdown calls dispose_engines().

Rollout

No migration. Readers given READ_DATABASE_URL stop connecting to DATABASE_URL, so a reader whose read database is a replica serves every query from it.

Evidence

  1. Tests alone (b2520b6, run 38136238575): the storage suite failed exactly the 9 new cases, each on the gap it pins; 952 passed.
    • L-37: a non-UTF-8 body to POST /agents and /agents/create-only answered 500. The two routers with local catches already answered 422 and passed.
    • L-139: a reader's get_session bound to primary.invalid, and shutdown disposed neither engine on any role. The control (writer and hybrid keep the primary) passed.
    • L-145: a database at 065, and one at 012, exited 1. The control (011 with vectors exits 1) passed.
    • L-97: the check listed exactly the 17 columns, each schema NULL and model NOT NULL.
  2. The fix (5e945fc): passed CI (run 38137030960), as did Eldad's rebase onto main, 6fd872e (run 38137217222).
  3. Review round 1: Claude Code Review found one Medium issue: the UnicodeDecodeError handler also answered 422 for decode failures unrelated to the body. Its test alone (7f7db5f, run 38137284764) failed on that handler with 422; 961 passed. The fix, 03d1b29: CI pending. Claude Code Review found no issues on it.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: draft PR

@eldad-caura-ai
eldad-caura-ai marked this pull request as ready for review October 11, 2026 11:51
@eldad-caura-ai
eldad-caura-ai requested a review from a team as a code owner October 11, 2026 11:51
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review by Claude Code

Summary

This PR does four things. It makes the model nullability match migration 001 (L-97). It sends every reader session to READ_DATABASE_URL and disposes both engines on shutdown (L-139). It moves the UTF-8 body 422 into an app-wide handler (L-37). It makes preflight_012 recognise databases already past 012 (L-145). The nullability, reader-engine and preflight changes look correct. One issue is in the new exception handler.

Medium/Low Issues

App-wide UnicodeDecodeError handler turns server-side decode failures into a misleading client 422

Severity: Medium
File: core-storage-api/src/core_storage_api/app.py:267-276
Problem: The handler is registered for every UnicodeDecodeError raised anywhere in the app, not only the one from request.json(). A decode failure on the server side, such as decoding bytes from a DB driver, a file or a secret, would be reported as a 422 "request body must be valid JSON". That is a caller error, so core-api would treat it as non-retryable. It would also stop appearing as a 500 for alerting.

🤖 Claude Code Prompt
In /workspace/core-storage-api/src/core_storage_api/app.py, around lines 267-276, `_malformed_json_handler` is registered with `@app.exception_handler(UnicodeDecodeError)` as well as `json.JSONDecodeError`. This makes every UnicodeDecodeError in the whole app, including ones unrelated to the request body, return 422 "request body must be valid JSON". Server-side decode faults are therefore hidden as client errors.

Change it so only body-parsing failures get the 422. Two ways to do that:
- Keep the JSONDecodeError handler. Add a small helper used by the routers that call `await request.json()`, or a dependency or middleware, that catches UnicodeDecodeError only around the body read and parse and raises the 422 there.
- Or keep an app-level handler for UnicodeDecodeError, but have it answer 422 only when the exception came from the request-body read. For example, mark it at the parse site. Otherwise, log the error and return the normal 500.

Keep the existing behaviour for the routers that parse their body inline (agents, agents/create-only, tenant-suppression, preview/tenant-counts). Also keep the 422 body {"detail": "request body must be valid JSON"}.

Reviewed by claude-sonnet-5-5 · cost $0.27447375

@eldad-caura-ai
eldad-caura-ai force-pushed the b28-upgrade-readiness branch 3 times, most recently from 7f7db5f to 03d1b29 Compare October 11, 2026 12:11
@eldad-caura-ai

Copy link
Copy Markdown
Member Author

Addressed the Medium finding from the review, the app-wide UnicodeDecodeError handler. The fix is in 03d1b29.

  • What changed: UnicodeDecodeError has its own handler again, separate from the JSONDecodeError one. It answers 422 only when the exception was raised inside Request.json, that is, while parsing the request body (_raised_parsing_the_body walks the traceback for that frame). Any other decode failure is re-raised, so it stays the 500 it was before this PR. The JSONDecodeError handler is unchanged from main.
  • Test first: test_l37_a_decode_failure_past_the_body_stays_a_500 makes a storage method raise UnicodeDecodeError while serving a valid request. Pushed alone as 7f7db5f, it failed on the previous handler with a 422 (run 38137284764, 1 failed, 961 passed). The non-UTF-8 body cases still expect 422, on the routers that parse inline and on the two that used to catch it locally.

🤖 Generated with Claude Code

@eldad-caura-ai

Copy link
Copy Markdown
Member Author

@claude please review

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review by Claude Code

Claude Code Review ✅ No issues found.


Reviewed by claude-sonnet-5-5 · cost $0.26764675

@eldad-caura-ai

Copy link
Copy Markdown
Member Author

@erni-a please review

@eldad-caura-ai
eldad-caura-ai force-pushed the b28-upgrade-readiness branch 2 times, most recently from ab938d8 to 2fd9317 Compare October 11, 2026 12:58
L-37: a body that is not valid UTF-8 answers 422. It fails before
JSON parsing, with UnicodeDecodeError, so every router that parses
its body inline answered 500, which core-api retries as a 503. The
app-wide handler answers 422 only for a decode raised parsing the
body; any other stays a 500. The local catches in
tenant_suppression and preview go.

L-139: a reader given READ_DATABASE_URL opens every session there.
Only get_read_session followed it; the get_session call sites,
about two thirds, still reached DATABASE_URL, the primary on a
reader set up from the README. Shutdown disposes every engine the
process built, on any role. The README says so.

L-145: preflight_012 places the head on the migration chain first.
At 012 or later it reports 012 applied, counts nothing and exits 0;
it had reported every re-embedded database past 012 destructive.

L-97: the models declare nullable the 17 columns migration 001
created nullable, as #1794 did for their JSON types, and a check
fails on any nullability drift. The PATCH guard still refuses a
null weight.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@eldad-caura-ai
eldad-caura-ai merged commit 67a0a57 into main Oct 11, 2026
16 checks passed
@eldad-caura-ai
eldad-caura-ai deleted the b28-upgrade-readiness branch October 11, 2026 13:17
@caura-deploy-bot caura-deploy-bot Bot mentioned this pull request Oct 11, 2026
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
The follow-up to #2033 (Eldad, 2026-10-11: no NOT NULL migration;
close the one path that stores a NULL instead). The storage bulk
insert rendered a bulk item's explicit "weight": null or
"created_at": null as NULL, which both columns accept, since
migration 001 created them nullable. A NULL weight made the row's
search score NULL.

Each None now gets what an absent key gets, in its place, so the
batch keeps one column list: created_at the per-row stamp, weight
the column's DEFAULT. The single-row write already left a None out
of its INSERT.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
The follow-up to #2033 (Eldad, 2026-10-11: no NOT NULL migration;
close the one path that stores a NULL instead). The storage bulk
insert rendered a bulk item's explicit "weight": null or
"created_at": null as NULL, which both columns accept, since
migration 001 created them nullable. A NULL weight made the row's
search score NULL.

Each None now gets what an absent key gets, in its place, so the
batch keeps one column list: created_at the per-row stamp, weight
the column's DEFAULT. The single-row write already left a None out
of its INSERT.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
## Why

The follow-up to L-97 from #2033. Migration 001 created
`memories.weight` and `memories.created_at` nullable, and #2033 made the
models say so. Eldad decided on 2026-10-11 not to add a NOT NULL
migration, and to close the one path that stores a NULL instead.

That path is the storage bulk insert. `POST /memories/bulk` hands the
request items to `memory_add_all`, whose multi-values INSERT renders a
`None` as an explicit NULL, and the nullable columns keep it. A NULL
weight makes the row's search score NULL, and a NULL `created_at` drops
the row out of every ordering and time window. core-api's own writers
set a weight first, so only a direct storage caller reaches this.

The single-row write never did: the ORM leaves a `None` out of the
INSERT, so the server default applies.

## What changes

`memory_add_all` gives each explicit `None` what an absent key already
gets, in its place, so the batch keeps one column list (the multi-values
INSERT requires it):

- `created_at`: the per-row stamp, `now()` plus the row's batch index in
microseconds, which a batch that sends no `created_at` already gets so
rows keep their order.
- `weight`: the column's own `DEFAULT` (0.5), rather than a restated
value.

The single-row write is unchanged: the ORM already leaves a `None` out
of its INSERT. No migration.

## Evidence

1. **Tests alone** (66e94ca, [run
38142891984](https://github.com/caura-ai/caura/actions/runs/38142891984)):
the storage suite failed exactly the new bulk case, on the gap it pins:
the item with `"weight": null` was stored with a NULL weight (`None ==
0.5`). The single-write control passed on main; 963 passed.
2. **The fix** (eb6d450): Claude Code Review found no issues on it.
Eldad rebased it onto main as 77737e4 (same patch-id): _CI pending_.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai pushed a commit that referenced this pull request Oct 11, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>backend: 3.30.7</summary>

##
[3.30.7](backend-v3.30.6...backend-v3.30.7)
(2026-10-11)


### Bug Fixes

* a bulk item's null weight gets the column default (L-97)
([#2036](#2036))
([25bc89e](25bc89e))
* **client-python:** raise CauraAPIError for non-JSON 2xx bodies
([#2013](#2013))
([d6580f7](d6580f7))
* **client-typescript:** raise CauraApiError for a non-JSON 2xx body
([#2014](#2014))
([9ba140d](9ba140d))
* **clients:** parse HTTP-date Retry-After values
([#2010](#2010))
([f575803](f575803))
* **events:** type pull_executor concretely in _hold_leases
([#1596](#1596))
([#1698](#1698))
([45dff80](45dff80))
* **release:** keep the core-operations version in sync
([#1717](#1717))
([96a7907](96a7907))
* storage answers for the database it has (B28)
([#2033](#2033))
([67a0a57](67a0a57))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: release-please[bot] <release-please[bot]@users.noreply.github.com>
Co-authored-by: caura-deploy-bot[bot] <265395343+caura-deploy-bot[bot]@users.noreply.github.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.

2 participants