Skip to content

fix(resolutions): refresh the upserted row before serialising it - #368

Merged
smaramwbc merged 1 commit into
mainfrom
fix/resolutions-update-500
Aug 31, 2026
Merged

fix(resolutions): refresh the upserted row before serialising it#368
smaramwbc merged 1 commit into
mainfrom
fix/resolutions-update-500

Conversation

@smaramwbc

Copy link
Copy Markdown
Owner

Closes #367.

The problem

POST /v1/resolutions upserts by (subject_id, session_id, tenant_id). The first write to a logical key returns 200; every write after it returns 500 internal_error and persists anyway.

200
500
500
500     # four writes to one key — and the read-back shows the fourth value

The only upsert-by-logical-key on the consumer surface was telling callers their write had failed, after committing it.

Root cause

create_resolution serialises the row it gets back after the commit:

result = await repo.upsert_resolution(session, row)
await session.commit()

return ResolutionResponse.from_row(result)

session.commit() expires every instance in the session, so reading a column off result is lazy IO. On the async engine that raises MissingGreenlet, which falls into the catch-all handler in server/core/errors.py and reaches the client as a 500.

It only bit the UPDATE path, which is why it survived this long. upsert_resolution returns the instance the request just constructed on INSERT — attributes still populated in Python, no refresh needed. On UPDATE it returns the row it loaded from the database, which the commit expired.

The fix

The pattern POST /v1/episodes already uses for the same reason: commit, then await session.refresh(result). One line plus the comment explaining which path it protects and why.

I audited the other routers rather than assuming this was the only instance. subjects.py reads only plain ints after its commit; health.py I checked empirically — 200 on repeated calls. resolutions.py was the one route that commits and then reads ORM attributes without refreshing.

What this is not

Tests

tests/integration/test_resolutions_upsert_update.py, three cases, all three fail on main:

test asserts
test_repeated_writes_to_one_key_all_succeed four writes to one key, every one a 200
test_the_update_response_carries_the_new_value the body describes the write that just happened — a status-code-only assertion would pass against a route returning a stale first-write body
test_the_upsert_still_collapses_to_one_row one logical key, one row, latest value, and resolved_at cleared when the status moves back off resolved

Verification

  • New tests: 3 passed with the fix, 3 failed without it (MissingGreenlet in the captured log).
  • tests/integration -k "resolution or sla or golden or claim or timeline or edge": 57 passed.
  • ruff check server/ tests/: clean.
  • Unit suite: 960 passed. tests/test_config.py::test_compiler_type_default_is_valid and ::test_embedding_provider_default_is_valid fail in a whole-directory run both with and without this change — test-order pollution, they pass when that file runs alone. Unrelated.

Run against Postgres 16 + pgvector with no API key and no LLM key, from source and against the published statewavedev/statewave:1.5.0 image.

POST /v1/resolutions upserts by (subject_id, session_id, tenant_id), and
every write after the first returned 500 while persisting the value. The
only upsert-by-logical-key on the consumer surface told callers their
write had failed, after committing it.

`create_resolution` serialises the row it gets back after committing.
`session.commit()` expires every instance in the session, so reading a
column off it is lazy IO, which raises MissingGreenlet on the async
engine and falls into the catch-all handler as 500/internal_error.

It only bit the UPDATE path. `upsert_resolution` returns the instance
this request just constructed on INSERT, whose attributes are still
populated in Python; on UPDATE it returns the row it loaded from the
database, which the commit expired.

The fix is the pattern POST /v1/episodes already uses for the same
reason: commit, then refresh. resolutions.py was the one router that
commits and then reads ORM attributes without refreshing — subjects.py
reads only plain ints afterwards, and health.py returns 200 on repeated
calls.

Not a regression from #295/#356: `from_row` reads the same nine fields
the inline mapping read, also after the commit. b99596b~1 behaves
identically (200, 500, 500). The defect dates to d4fef67.

Not covered by #366 either: that maps OperationalError to 503, and
MissingGreenlet is an InvalidRequestError.

Three regression tests, all failing on main: repeated writes to one key
all return 200; the update response carries the new value rather than a
stale first-write body; and the upsert still collapses to one row.
@smaramwbc
smaramwbc merged commit 408068f into main Aug 31, 2026
6 checks passed
@smaramwbc
smaramwbc deleted the fix/resolutions-update-500 branch August 31, 2026 14:53
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.

[Bug]: POST /v1/resolutions returns 500 on every update while persisting the value

1 participant