fix(resolutions): refresh the upserted row before serialising it - #368
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #367.
The problem
POST /v1/resolutionsupserts by(subject_id, session_id, tenant_id). The first write to a logical key returns 200; every write after it returns500 internal_errorand persists anyway.The only upsert-by-logical-key on the consumer surface was telling callers their write had failed, after committing it.
Root cause
create_resolutionserialises the row it gets back after the commit:session.commit()expires every instance in the session, so reading a column offresultis lazy IO. On the async engine that raisesMissingGreenlet, which falls into the catch-all handler inserver/core/errors.pyand reaches the client as a 500.It only bit the UPDATE path, which is why it survived this long.
upsert_resolutionreturns 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/episodesalready uses for the same reason: commit, thenawait 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.pyreads only plain ints after its commit;health.pyI checked empirically — 200 on repeated calls.resolutions.pywas the one route that commits and then reads ORM attributes without refreshing.What this is not
from_rowreads exactly the same nine fields the inline mapping read, also after the commit. I checked outb99596b~1and ran it —200, 500, 500, identical. The defect dates tod4fef67, where the endpoint was introduced.sqlalchemy.exc.OperationalErrorto 503.MissingGreenletis anInvalidRequestError, so it still falls to the catch-all.Tests
tests/integration/test_resolutions_upsert_update.py, three cases, all three fail onmain:test_repeated_writes_to_one_key_all_succeedtest_the_update_response_carries_the_new_valuetest_the_upsert_still_collapses_to_one_rowresolved_atcleared when the status moves back offresolvedVerification
MissingGreenletin the captured log).tests/integration -k "resolution or sla or golden or claim or timeline or edge": 57 passed.ruff check server/ tests/: clean.tests/test_config.py::test_compiler_type_default_is_validand::test_embedding_provider_default_is_validfail 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.0image.