Issue #1232: feat: mark partial Query Planner answers, 503 when nothing was cached - #1303
Conversation
…ng was cached run_query now returns LIFQueryPlannerPartialRecords (records + reason) on the two paths that degrade: no source serves the missing fields, and orchestrator submission failed. Both query endpoints return the records with an X-LIF-Partial header naming the reason. An empty answer after a failed submission becomes a 503, since as a 200 it read as "no such learner" and the export API reported it as a false 404. Fields still missing after orchestration are left unmarked: that most likely means the learner has no data for them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…mid-orchestration Measured in process against the real Dagster job: when a source fails every attempt, run_lif_adapter swallows it, the run succeeds, and the only trace is the part result's error. The Query Planner logged it and marked the job COMPLETED, so the sync /query returned a plain 200 that looked like a learner with no data. run_post_orchestration_results now records failed sources on the job, and the sync handler passes the job id to the second run_query, which returns partial records with reason source_failed. An empty answer after that is a 503, like an empty answer after a failed submission. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Evidence for the second commit: a failed source still produces a clean runWhen I first opened this PR, the case "orchestration ran and fields are still missing" was left unmarked on purpose, on the reasoning that it most likely meant the learner had no data for those fields. That depended on something I hadn't checked: whether a source failing inside the Dagster run would end the run as a failure (and so already surface as a 500), or whether it would come through looking like success. So I measured it. How it was measuredAn in-process probe runs the real What it showed (before the fix)
The chain behind it:
So a source that was down produced exactly the answer a learner with no data would: a The information was never really lost. The The fix (
|
bjagg
left a comment
There was a problem hiding this comment.
Approving. The table separating permanent from transient failures is the idea that makes this right. Deliberately not marking "every source succeeded, fields still missing" is what keeps the header from firing all the time and carrying no information.
I verified the three claims the design depends on. Each one would break something if wrong.
1. "Both direct consumers of /query check for exactly 200." Confirmed: type_factory.py uses == 200 and query_planner_client/core.py:52 uses != 200. I also searched wider (adapters, orchestrator, Advisor, semantic search) for a third caller that might read a 503 differently. The only other mention is lif_job.py:213's send_results_to_query_planner, which is Dagster posting results back to the callback endpoint, not calling /query. So there are exactly two consumers, and the header-not-206 reasoning holds.
2. Nothing iterates the new return type. run_query has exactly three call sites, all in query_planner_restapi/core.py (:178, :200, :237), and all three dispatch on LIFQueryPlannerPartialRecords before returning. No caller can be surprised by a wrapper where it expected a list.
3. The export API stops reporting a false 404. Confirmed: a 503 makes fetch_query_from_query_planner raise QueryPlannerException, which learner_data_export_endpoints.py:128-130 turns into 500 "Unable to retrieve learner data from Query Planner". It no longer falls through to :136-138's "did not find any results". That false 404 was the worst symptom in #1232, and it's gone.
The 503 doesn't reopen #1291's leak. Its detail carries partial.reason, which is always one of the OUTCOME_* constants and never backend text. Reusing the statistics vocabulary for the header, so logs and response use the same words, is a nice touch.
Mutation-checked, all four killed:
| mutation | result |
|---|---|
| never 503 (empty + transient failure back to an empty 200) | 3 failed |
also 503 on no_sources_available (permanent, must stay 200) |
1 failed |
stop setting X-LIF-Partial |
4 failed |
stop recording failed_source_ids during orchestration |
2 failed |
The second is the design line in your table, and test_core.py:489, an empty no_sources_available partial expecting 200 plus the header, is what pins it. That's the test I'd most want to exist, since "helpfully" adding the permanent case to the 503 set would tell callers to retry a config problem. 74 pass in the affected components and 873 across the full suite.
On your MIGRATION.md question: yes, add it. The header is additive, but the 503 isn't. A caller that treats an empty 200 as "this learner has no data" now gets an error in one case. #1291 made the same kind of change (a failure reported as an error rather than an empty result) and recorded it in MIGRATION.md, so doing the same here keeps the two consistent.
Merge order. I agree the conflicts with #1301 and #1302 are keep-both. Your GraphQL relay follow-up should come after #1291, which is now approved.
One forward-looking nit, not blocking. Every reader of X-LIF-Partial is server-to-server today, so CORS doesn't apply. If a browser client ever reads it directly, it will also need Access-Control-Expose-Headers, or the header will be invisible to the page.
#1301 and #1302 landed in the same files, as the PR description anticipated. Every overlap was keep-both in meaning, but three hunks had to be combined rather than stacked: - run_query takes both `client` (#1301) and `query_id` (this PR), and returns the widened type including LIFQueryPlannerPartialRecords. - the sync handler's re-run passes `client=client, query_id=...` and keeps this PR's partial-records dispatch. - datatypes.py keeps #1302's `org_key` inside LIFQueryPlannerConfig, with LIFQueryPlannerPartialRecords after it. Verified by AST that org_key is a field of the config, not of the new class. The two test files were merged per function, not by text hunk: a text union separated `_post_query`'s body from its def and a @patch from its test. This PR adds 9 + 4 functions, changes 2 that main left untouched, and adds two imports. Test counts are exact (15, 36) with no duplicates. Gate: ruff, format, ty, 902 tests. Mutants from #1301 (fullmatch), #1302 (blank org key) and this PR (503 scope, partial header, query_id on the re-run) all fail a test on the combined tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Description of Change
Problem.
LIFQueryPlannerService.run_queryhas paths where it deliberately degrades instead of failing. It returns200with the cached records, even though they're missing fields the caller asked for, and nothing in the response tells a partial answer from a complete one (#1232). The worst case is a learner who isn't in the cache yet: if orchestrator submission fails, the answer is an empty200. GraphQL shows that as "no such learner", and the learner data export API turns it into 404 "Query Planner did not find any results" (learner_data_export_endpoints.py:134-138), which is false.The three paths are not the same kind of event, so they're treated differently:
200+X-LIF-Partial: no_sources_available200+X-LIF-Partial: orchestrator_submission_failed, or503if nothing was cached/query)200+X-LIF-Partial: source_failed, or503if nothing was cachedMarking that last path would flag every learner who has, say, no
EmploymentPreferences. The flag would fire all the time and carry no information.Solution.
run_queryreturns a new component-local type,LIFQueryPlannerPartialRecords(records, reason), on the first two paths. The handlers already dispatch onisinstance(result, LIFQueryStatusResponse), so this follows the same pattern. The reason values are theOUTCOME_*constants the query statistics (Issue #341: feat: emit Query Planner query statistics as structured log events #1273) already record for those paths, so the logs and the header use the same words.c239d6a):run_post_orchestration_resultsrecords the failed sources onLIFQueryPlannerJob.failed_source_ids, and the sync handler passes the job id to the secondrun_query(query_id=), which returns partial records with reasonsource_failed. The evidence for this is in the comment below.respond_to_partial_records) is shared by/queryand/query_async. It sets the header, or raises503for the empty + submission-failed case. HTTP policy stays in the base.query_planner_service/datatypes.py, not the sharedlif.datatypesbrick. Onlylif_query_planner_apipackagesquery_planner_service, and thelif_query_planner_api.ymlpaths:filter covers both changed bricks.Why a header, not
206or a wrapper body. Both direct consumers of/querycheck for exactly200:openapi_to_graphql/type_factory.py(== 200) andquery_planner_client/core.py(!= 200raises). A206would break both. A wrapper body changesresponse_model=List[LIFRecord]for every adopter. The header is purely additive.Why the 503. It matches #1264 / #1291: a total failure is reported as an error, not dressed up as an empty result. The export API now reports "Unable to retrieve learner data" (500) instead of the false 404.
Limitations and follow-ups (not filed; for discussion here):
extensions. That's the follow-up, and it should come after Issue #1264: fix: surface a Query Planner failure as a GraphQL error, not an empty result #1291, which touches the same resolver. That's why this PR saysRefs #1232rather thanCloses.source_failedis sync/queryonly. An/query_asyncclient re-POSTs after polling, which callsrun_query(first_run=True)with no link to the job it polled, so there is nothing to attach the failure to. Aquery_idon that re-POST, or results served from the status endpoint, would be a contract change, so it's left out here.How reviewers should test. Stop the orchestrator (or point
LIF_ORCHESTRATOR_URLat a closed port), then query a learner who isn't in the cache: you should get503. Query one who is partly cached:200withX-LIF-Partial: orchestrator_submission_failed. Ask for a field no configured source serves:200withX-LIF-Partial: no_sources_available.Related Issues
Refs #1232
Refs #1131
Refs, notCloses, for #1232: its third acceptance criterion (downstream consumers updated or explicitly deferred) needs the GraphQL relay above. #1204 was closed today, pointing to #1235 and #1232.Type of Change
The header is additive. The
503replaces an empty200in one failure case. It's recorded inCHANGELOG.mdbut not inMIGRATION.md; happy to add a MIGRATION entry if you count it as breaking.Project Area(s) Affected
Checklist
uv run ruff check)uv run ruff format)uv run ty check)docs/and project README updated (both brick READMEs updated)
Testing
Automated tests added/updated
Component: the no-sources and submission-failed tests now assert the partial type and its reason. A new test pins the post-orchestration path as a plain, unmarked list.
Base: header on a partial answer, run through
TestClientso the header is shown to reach the wire; no header on a complete answer;503for empty + submission-failed on both endpoints; an empty no-sources answer stays a marked200;/query_asyncis marked too.Source failures: failed sources are recorded on the job; a re-run after a job with a failed source is marked
source_failed; a re-run after a clean job stays unmarked; the sync handler passesquery_idthrough and marks the answer; empty +source_failedis503.Guard check: with only the
run_querychange reverted, exactly the two path tests fail. The base tests failed before the handler change. For the second commit, reverting the component alone fails exactly its 3 new component tests.Query Planner suites: 74 passed.
pre-commit run --fileson all 8 files (ruff, format, cspell, ty, full pytest) passed.Additional Notes
Merge order with #1301 / #1302. Those two touch the same files, and a trial merge conflicts in
query_planner_service/core.py(therun_querysignature: #1301 addsclient, this adds a return type),datatypes.py(an import line; a new class next to #1302'sorg_keyfield), and tests added at the same place in both test files. Every hunk is keep-both; nothing overlaps in meaning. Whichever lands second getsmainmerged in (not rebased). Trial merges against #1291 and #1299 are clean.🤖 Generated with Claude Code