Skip to content

Issue #1271: feat: record which organization's Query Planner emitted each statistic - #1302

Merged
bjagg merged 3 commits into
mainfrom
issue-1271-lif-org-key
Sep 24, 2026
Merged

bjagg merged 3 commits into
mainfrom
issue-1271-lif-org-key

Conversation

@dereck-symmetry

Copy link
Copy Markdown
Contributor

Stacked on #1301 — merge #1301 first. This branch is cut from #1301's, so until that merges this diff also shows its commit (01138b3). Review only 3845e36, which is this PR. The base is main so CI runs.

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_KEY did not exist anywhere in the repo.

Solution. A new optional LIF_ORG_KEY, read once by the base into LIFQueryPlannerConfig.org_key and recorded as org_key on both statistics events (query_planned and query_completed), next to #1301's client.

Deployment config. This is the live-environment part, so here is what it does precisely:

  • No *.params file changes. cloudformation/lif-query-planner-taskdef-includes.yml already 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 sets OrganizationName in its params, and all six are in dev.aws / demo.aws. The unsuffixed *-lif-query-planner.params files aren't deployed.
  • Local: deployments/advisor-demo-docker/docker-compose.yml sets LIF_ORG_KEY: org1/org2/org3 on the three planners.
  • What merging does, and doesn't do. The deploy workflow rebuilds the image and runs 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 without LIF_ORG_KEY and record org_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 demo
    Nobody has run that as part of this PR.

How 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
  • New feature (non-breaking change which adds functionality)
  • Infrastructure/deployment change
Project Area(s) Affected
  • bases/
  • components/
  • deployments/
  • cloudformation/ or sam/ templates
  • test/ or e2e/
  • Documentation (docs/, READMEs, ARCHITECTURE.md, CLAUDE.md)

Checklist
  • commit message follows commit guidelines (see commitlint.config.mjs)
  • tests are included (unit and/or integration tests)
  • code passes linting checks (uv run ruff check)
  • code passes formatting checks (uv run ruff format)
  • code passes type checking (uv run ty check)
  • pre-commit hooks have been run successfully
  • configuration changes: relevant folder README updated
Testing
  • Automated tests added/updated

The acceptance criteria, each with its test:

  • Read by the planner and included in every event. test_both_events_carry_the_configured_org_key goes from submission through the results callback. test_query_statistics_through_the_endpoint_carry_the_org_key runs through the real TestClient.

  • 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_unknown covers both unset and whitespace-only.

  • The variable name itself. It's read at import, so test_org_key_is_read_from_lif_org_key imports the base in a fresh interpreter, the same technique the timeout tests use, since importlib.reload is off-limits.

  • Both deployment files were parsed to confirm the entries land where intended:

    • the taskdef include's Environment has LIF_ORG_KEY: {Fn::Sub: ${OrganizationName}};
    • the three compose planners get org1/org2/org3.

    The Fn::Sub itself can't be exercised locally. Its form is identical to the existing LIF_QUERY_CACHE_URL entry in the same file.

Mutation-checked. Each of these mutants fails at least one test:

  • the config never receives org_key;
  • the wrong env var name;
  • a blank value not treated as unset;
  • the planned event omits it;
  • the completed event omits it.
Additional Notes

🤖 Generated with Claude Code

dereck and others added 2 commits September 23, 2026 19:24
… 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 bjagg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.sh calls login() unconditionally (line 20), which sources scripts/login.sh.
  • login.sh sets AWS_CONFIG_FILE=$PWD/.aws/config and runs a bare aws sso login with no --profile.
  • The repo-local .aws/config defines 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-1

That'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>
@bjagg
bjagg merged commit b7b87ff into main Sep 24, 2026
4 checks passed
@bjagg
bjagg deleted the issue-1271-lif-org-key branch September 24, 2026 19:57
bjagg added a commit that referenced this pull request Sep 24, 2026
#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>
bjagg added a commit that referenced this pull request Sep 24, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Query Planner has no org identity: LIF_ORG_KEY, so every statistic collapses into one bucket

2 participants