Issue #1271: feat: record which organization's Query Planner emitted each statistic - #1302
Conversation
… X-LIF-Client header Every query statistics event now carries `client`. The header is optional: absent means "unknown", never a rejected query. A value that is not a short lowercase name is recorded as "invalid" rather than as itself, keeping free text and person data out of the logs. Learner Data Export sends learner-data-export and the MCP server sends semantic-search-mcp. GraphQL forwards its caller's name, or sends graphql, so MCP traffic keeps its origin through the extra hop. The job record remembers the caller for the completion event, since the orchestrator's results callback carries none. Adds graphql_client to the MCP deploy workflow's paths filter; without it, merging would not rebuild the image that sends the new header. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…each statistic One planner runs per org, but nothing told it which, so every statistics event from all three landed in one bucket. A new optional LIF_ORG_KEY is recorded as org_key on both events; unset or blank records "unknown" rather than failing a query. Deployed from the stack's OrganizationName via the shared taskdef include, so no *.params file changes and there is one org naming scheme (org1/org2/org3), not two. Local compose sets it per planner. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bjagg
left a comment
There was a problem hiding this comment.
Approving. Reviewed 3845e36 only, as asked. The branch carries #1301's 01138b3 underneath, and #1301 is now approved separately.
The "what merging does, and doesn't do" section is the part I most wanted to see. It says plainly that the image redeploy reuses the existing task definition, so dev and demo run the new code without LIF_ORG_KEY until someone deploys the stacks. That's the exact trap #1239 hit, which cost four hours of crash-looping on dev. Because this one fails safe, as unknown rather than a crash, the worst case is a statistic that stays unknown until someone deploys, not an outage.
One correction to that section: don't use aws-deploy.sh for the stack deploy. I found this while fixing the #1239 incident, and it's written up in #1287:
aws-deploy.shcallslogin()unconditionally (line 20), which sourcesscripts/login.sh.login.shsetsAWS_CONFIG_FILE=$PWD/.aws/configand runs a bareaws sso loginwith no--profile.- The repo-local
.aws/configdefines only[profile AWS-Gates-CLIR]and no default. So which account the rest of the script acts on depends on the operator's ambient AWS environment, not on the script. On my machine it resolved to a different account entirely.
The safe equivalent pins the profile and calls the underlying script directly:
export AWS_PROFILE=lif
aws sts get-caller-identity --query Account --output text # expect 381492161417
aws s3 sync --size-only --exclude="*" --include="*.yml" --include="*.params" \
cloudformation s3://clir-cf-templates/dev-lif
./scripts/cfn-deploy.sh -s dev-lif-query-planner-org1 \
-t "https://clir-cf-templates.s3.amazonaws.com/dev-lif/service.yml" \
-r us-east-1 -p cloudformation/dev-lif-query-planner-org1.params \
--no-wait-for-completion --changeset-name dev-lif-query-planner-org1-dev
./scripts/cfn-wait.sh dev-lif-query-planner-org1 us-east-1That's what I used to fix the identity-mapper stacks. Worth swapping into the PR body so whoever does the deploy doesn't use the risky path.
The per-org value is proven syntax, not new syntax. A single shared include has to yield org1/org2/org3. The new entry uses Fn::Sub: "${OrganizationName}", the same long form that three entries in this include already deploy successfully (LIF_QUERY_CACHE_URL at :4, the orchestrator URL at :7, the SSM ARN at :34). So it works inside an AWS::Include snippet, and it resolves to the same OrganizationName that names query-cache-org1.
Mutation-checked, all three killed:
| mutation | result |
|---|---|
drop the blank → unknown fallback |
1 failed |
never pass org_key into the config |
1 failed |
omit org_key from query_completed |
2 failed |
The first one matters most. Blank is exactly what a CloudFormation Value: yields when its source is missing, so without that fallback, a half-deployed stack would record an empty string rather than a recognisable unknown. 78 pass in the affected components and 882 across the full suite.
This branch was stacked on #1301, which landed as a squash, so its copy of #1301's commit no longer matched main. A plain three-way merge produced 13 hunks across 6 files: this branch adds org_key lines directly adjacent to the client lines #1301 added, and adjacent insertions conflict even when one side is a strict superset of the other. Resolved by construction rather than hunk by hunk: the merge result is main with this PR's own commit (3845e36) applied on top, which cherry-picked onto main with no conflicts. The changed-file set against main is exactly 3845e36's 11 files. Gate: ruff, format, ty, 890 tests. Re-checked that dropping the blank-to-unknown fallback still fails a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#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>
…ng was cached (#1303) ##### Description of Change **Problem.** `LIFQueryPlannerService.run_query` has paths where it deliberately degrades instead of failing. It returns `200` with 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 empty `200`. 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: | Path | What it means | Now | |---|---|---| | No source can serve the missing fields | Permanent (config); a retry won't help | `200` + `X-LIF-Partial: no_sources_available` | | Orchestrator submission failed | Transient; a retry may succeed | `200` + `X-LIF-Partial: orchestrator_submission_failed`, or **`503` if nothing was cached** | | Orchestration ran and **a source failed** (sync `/query`) | Transient; Dagster swallows it and the run still succeeds | `200` + `X-LIF-Partial: source_failed`, or **`503` if nothing was cached** | | Orchestration ran, every source succeeded, fields still missing | Most likely the learner has no data for them | Unchanged, **deliberately unmarked** | Marking 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_query` returns a new component-local type, `LIFQueryPlannerPartialRecords(records, reason)`, on the first two paths. The handlers already dispatch on `isinstance(result, LIFQueryStatusResponse)`, so this follows the same pattern. The reason values are the `OUTCOME_*` constants the query statistics (#1273) already record for those paths, so the logs and the header use the same words. - **Source failures during orchestration** (second commit, `c239d6a`): `run_post_orchestration_results` records the failed sources on `LIFQueryPlannerJob.failed_source_ids`, and the sync handler passes the job id to the second `run_query` (`query_id=`), which returns partial records with reason `source_failed`. The evidence for this is in the comment below. - In the base, one helper (`respond_to_partial_records`) is shared by `/query` and `/query_async`. It sets the header, or raises `503` for the empty + submission-failed case. HTTP policy stays in the base. - The new type lives in `query_planner_service/datatypes.py`, not the shared `lif.datatypes` brick. Only `lif_query_planner_api` packages `query_planner_service`, and the `lif_query_planner_api.yml` `paths:` filter covers both changed bricks. **Why a header, not `206` or a wrapper body.** Both direct consumers of `/query` check for exactly `200`: `openapi_to_graphql/type_factory.py` (`== 200`) and `query_planner_client/core.py` (`!= 200` raises). A `206` would break both. A wrapper body changes `response_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):** - **Nobody reads the header yet.** The Advisor and MCP only see GraphQL, so the signal reaches them only once GraphQL relays it, e.g. into the response `extensions`. That's the follow-up, and it should come after #1291, which touches the same resolver. That's why this PR says `Refs #1232` rather than `Closes`. - The export API could read the header too. Deferred. - **`source_failed` is sync `/query` only.** An `/query_async` client re-POSTs after polling, which calls `run_query(first_run=True)` with no link to the job it polled, so there is nothing to attach the failure to. A `query_id` on 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_URL` at a closed port), then query a learner who isn't in the cache: you should get `503`. Query one who is partly cached: `200` with `X-LIF-Partial: orchestrator_submission_failed`. Ask for a field no configured source serves: `200` with `X-LIF-Partial: no_sources_available`. ##### Related Issues Refs #1232 Refs #1131 `Refs`, not `Closes`, 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 - [x] New feature (non-breaking change which adds functionality) The header is additive. The `503` replaces an empty `200` in one failure case. It's recorded in `CHANGELOG.md` but not in `MIGRATION.md`; happy to add a MIGRATION entry if you count it as breaking. ##### Project Area(s) Affected - [x] bases/ - [x] components/ - [x] test/ or e2e/ - [x] API endpoints - [x] Documentation (docs/, READMEs, ARCHITECTURE.md, CLAUDE.md) --- ##### Checklist - [x] commit message follows commit guidelines (see commitlint.config.mjs) - [x] tests are included (unit and/or integration tests) - [x] code passes linting checks (`uv run ruff check`) - [x] code passes formatting checks (`uv run ruff format`) - [x] code passes type checking (`uv run ty check`) - [x] pre-commit hooks have been run successfully - [x] API changes: base (Python code) documentation in `docs/` and project README updated (both brick READMEs updated) ##### Testing - [x] 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 `TestClient`** so the header is shown to reach the wire; no header on a complete answer; `503` for empty + submission-failed on both endpoints; an empty no-sources answer stays a marked `200`; `/query_async` is 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 passes `query_id` through and marks the answer; empty + `source_failed` is `503`. - **Guard check:** with only the `run_query` change 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 --files` on 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` (the `run_query` signature: #1301 adds `client`, this adds a return type), `datatypes.py` (an import line; a new class next to #1302's `org_key` field), and tests added at the same place in both test files. **Every hunk is keep-both;** nothing overlaps in meaning. Whichever lands second gets `main` merged in (not rebased). Trial merges against #1291 and #1299 are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: dereck <dereck.haskins@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Benito J. Gonzalez <bgonzalez@unicon.net>
Description of Change
Problem. One Query Planner runs per organization (
query-planner-org1/2/3), but nothing in its environment says which, so every statistics event #341 emits collapses into one bucket across all three orgs.LIF_ORG_KEYdid not exist anywhere in the repo.Solution. A new optional
LIF_ORG_KEY, read once by the base intoLIFQueryPlannerConfig.org_keyand recorded asorg_keyon both statistics events (query_plannedandquery_completed), next to #1301'sclient.unknown, and the query proceeds as before. Blank is treated as unset because that's what a CloudFormationValue:yields when its source is missing (the base's Config hygiene: unguarded int(os.getenv(...)) at module scope crash-loops services on a malformed value (6 on main, 3 more in flight) #1179 convention).OrganizationName(org1/org2/org3), the same one that names each planner's stack, ECS service, compose container and SSM parameters. I consideredSEED_DATA_KEY'sadvisor-demo-orgN, but it names a sample dataset, not the organization.Deployment config. This is the live-environment part, so here is what it does precisely:
*.paramsfile changes.cloudformation/lif-query-planner-taskdef-includes.ymlalready builds values from${OrganizationName}(e.g.LIF_QUERY_CACHE_URL), so a single entry,LIF_ORG_KEY: Fn::Sub "${OrganizationName}", covers all six deployed planners:{dev,demo}-lif-query-planner-org{1,2,3}. Each setsOrganizationNamein its params, and all six are indev.aws/demo.aws. The unsuffixed*-lif-query-planner.paramsfiles aren't deployed.deployments/advisor-demo-docker/docker-compose.ymlsetsLIF_ORG_KEY: org1/org2/org3on the three planners.aws ecs update-service, which redeploys with the existing task definition. It doesn't update the stack. So after merge, dev and demo run the new image withoutLIF_ORG_KEYand recordorg_key: "unknown". That's the graceful fallback, and it's safe to leave in place. The variable arrives when someone runs, sequentially:./aws-deploy.sh -s dev --only-stack dev-lif-query-planner-org1 # then org2, org3; same for demoHow to test.
uv run pytest test/components/lif/query_planner_service test/bases/lif/query_planner_restapi.Related Issues
Closes #1271
Refs #341
Refs #1131
Type of Change
Project Area(s) Affected
Checklist
uv run ruff check)uv run ruff format)uv run ty check)Testing
The acceptance criteria, each with its test:
Read by the planner and included in every event.
test_both_events_carry_the_configured_org_keygoes from submission through the results callback.test_query_statistics_through_the_endpoint_carry_the_org_keyruns through the realTestClient.Absence degrades gracefully.
test_a_planner_without_an_org_key_still_serves_the_query_and_emits_unknown.test_unset_or_blank_org_key_falls_back_to_unknowncovers both unset and whitespace-only.The variable name itself. It's read at import, so
test_org_key_is_read_from_lif_org_keyimports the base in a fresh interpreter, the same technique the timeout tests use, sinceimportlib.reloadis off-limits.Both deployment files were parsed to confirm the entries land where intended:
EnvironmenthasLIF_ORG_KEY: {Fn::Sub: ${OrganizationName}};org1/org2/org3.The
Fn::Subitself can't be exercised locally. Its form is identical to the existingLIF_QUERY_CACHE_URLentry in the same file.Mutation-checked. Each of these mutants fails at least one test:
org_key;Additional Notes
CHANGELOG.md. No other open PR touches the taskdef include or the compose planners.lif_query_planner_api.yml'spaths:already includescloudformation/lif-query-planner-taskdef-includes.ymland both planner bricks, so merging rebuilds the image. As above, that doesn't update the task definition.🤖 Generated with Claude Code