feat(b2b_analytics): add learner-grain MVs for the learner-records API - #2669
feat(b2b_analytics): add learner-grain MVs for the learner-records API#2669blarghmatey wants to merge 2 commits into
Conversation
ol-analytics-api's b2b_learner_records tenant serves per-learner progress to site-licence partners, and every existing mv_b2b_* view is pre-aggregated by design. Two new views back it: - mv_b2b_learner_enrollment: org x contract x course_run x learner. The funnel view's joins without the group by, plus dim_user, grade and certificate. Emits is_passing and certificate state separately; the API derives completion_status from both. - mv_b2b_learner: org x learner. Unions the membership roster (bridge_user_organization) with active enrollments, so assigned-but-unstarted seats appear, and emits membership_source (roster | enrollment | both). The organization is attributed only through the contract that owns the course run, never organization_administration_report's free-text fallback. learner_id is user_global_id; user_pk is emitted as an internal join key only, since it can still re-key. The rollup reads the dimensional sources directly rather than selecting from mv_b2b_learner_enrollment: the refresh asset refreshes MVs in name order, which would refresh mv_b2b_learner first and serve the previous refresh's data. No consent or activity columns yet. The API returns outcomes_shared=false until the consent field lands, and nulls last_active_on and the activity counters until a user_fk-keyed activity fact exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FmjwANjfgtuKAtsRPf6Ly
🔎 ol-dbt impact — column-level blast radius0 breaking, 0 surrogate-key regeneration, 0 warning, 2 info across 2 changed model(s). Details
Posted by |
There was a problem hiding this comment.
🟡 Changes recommended
Cursor completeness, nullable learner identifiers, and PII schema isolation must be addressed.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds learner-grain StarRocks materialized views for the proposed B2B learner-records API.
Changes:
- Adds enrollment-detail and organization-level learner views.
- Adds learner identity, roster, completion, and certificate fields.
- Documents the new models and dimensional sources.
File summaries
| File | Description |
|---|---|
mv_b2b_learner.sql |
Adds organization-level learner rollups. |
mv_b2b_learner_enrollment.sql |
Adds per-enrollment learner records. |
_b2b_analytics__sources.yml |
Declares learner and roster sources. |
_b2b_analytics__models.yml |
Documents both materialized views. |
Review details
Suppressed comments (4)
src/ol_dbt/models/b2b_analytics/mv_b2b_learner.sql:132
record_updated_oncannot serve the promisedupdated_sincecontract for this row. A newly added roster-only learner getsNULL, roster/manager changes and removals never advance it,dim_userchanges are ignored, and issuance/revocation of the program certificate counted above is not included. Incremental clients will therefore miss changed learner records. Please propagate timestamps for every source that can alter this row (and define deletion/tombstone behavior) before using this field as the cursor.
er.record_updated_on
src/ol_dbt/models/b2b_analytics/mv_b2b_learner_enrollment.sql:46
- This cursor advances only for enrollment, grade, and certificate facts, although the API contract defines
updated_sinceas records changed since the cursor. Changes to projected learner identity (email/full_name) or course-run metadata alter the API record without changing this value, so incremental clients retain stale data. Include change timestamps for all projected mutable sources or narrow the API's documented sync semantics.
greatest(
coalesce(e.enrollment_updated_on, e.enrollment_created_on),
coalesce(g.grade_updated_on, e.enrollment_created_on),
coalesce(cert.certificate_updated_on, e.enrollment_created_on)
) as record_updated_on
src/ol_dbt/models/b2b_analytics/mv_b2b_learner.sql:117
- The linked API schema requires
learner_idto be a non-null UUID and orders/learnersby that unique field, but this model explicitly permitsuser_global_idto be null (as documented in the accompanying YAML). Such rows cannot be serialized under that contract or paged by the promised key. Either exclude/backfill users without a global ID or make the API contract and identifier strategy nullable-safe.
u.user_global_id,
src/ol_dbt/models/b2b_analytics/mv_b2b_learner_enrollment.sql:27
- The linked API schema requires
learner_idto be a non-null UUID, while this view does not enforce a non-nulluser_global_idand its YAML states null is valid. Any such row cannot satisfy the enrollment response contract or its unique paging order. Add an explicit non-null policy here, backfill the identifier, or revise the API contract.
u.user_global_id,
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…chema Review on #2669: the two PII-bearing views sat in b2b_analytics next to the k-anonymized aggregate views, so no grant could reach one without the other. They now build into a b2b_learner_records StarRocks database, provisioned in ol-infrastructure. Dagster needs no change: the refresh asset and the drift check both take schema-qualified relations from the manifest. Also from review: - user_global_id (the API's required learner_id) gets a not_null test in both views, so a null fails the build instead of reaching a partner. - mv_b2b_learner's record_updated_on now includes program-certificate update times, which it counts but did not track. - record_updated_on is documented as covering enrollment, grade and certificate changes only. Roster, dim_user and course-run edits carry no change timestamp upstream; full change tracking is a follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FmjwANjfgtuKAtsRPf6Ly
|
Addressed Copilot's review. The two schema threads are fixed and resolved (f62117d, plus mitodl/ol-infrastructure#5835). Copilot's four suppressed comments:
|
What are the relevant tickets?
N/A. Upstream of the learner-records tenant specced in mitodl/ol-analytics-api#55 (
docs/b2b-learner-records-design.md§1, gaps 1, 3, 4, 5). Depends on mitodl/ol-infrastructure#5835.Description (What does it do?)
Every
mv_b2b_*view is pre-aggregated, so nothing can back a per-learner API. This adds two StarRocks MVs in a newb2b_learner_recordsdatabase (models/b2b_learner_records/). They're kept out ofb2b_analyticsso these PII-bearing views and the k-anonymized aggregate views can be granted separately.mv_b2b_learner_enrollment, at org × contract × course run × learner. It uses the funnel view's joins without thegroup by, plusdim_user, grade and certificate.is_passingand certificate state are emitted separately, and the API derivescompletion_statusfrom both.mv_b2b_learner, at org × learner. It unions the membership roster (bridge_user_organization) with active enrollments, so assigned-but-unstarted seats appear. It emitsmembership_source(roster/enrollment/both). Rollups count active enrollments only, which matches the API defaultinclude_inactive=false.Choices a reviewer should check:
organization_administration_report's free-textorganization_keyfallback.learner_idisuser_global_id, with anot_nulltest in both views.user_pkis emitted only as an internal join key, because it can still re-key.record_updated_onis theupdated_sincecursor. It covers enrollment, grade and certificate changes only, plus program certificates inmv_b2b_learner. Roster changes,dim_useredits and course-run edits carry no change timestamp upstream, so they don't move it. Full change tracking is a follow-up.mv_b2b_learnerreads the dimensional sources directly instead of selecting frommv_b2b_learner_enrollment.materialized_view_relationsrefreshes MVs in sorted name order, so the rollup would refresh first and serve the previous refresh's data.program_certificates_earnedcounts a program certificate only if the program contains a course the learner is actively enrolled in under the org's contracts. Otherwise a certificate earned outside the licence would be reported to the partner.There are no consent or activity columns yet. The API returns
outcomes_shared: falseand null activity fields until those land upstream.No Dagster change is needed. The refresh asset and the drift check both take schema-qualified relations from the manifest, and the drift query already filters on a list of schemas.
How can this be tested?
ol-dbt validate: 0 errors. Pre-commit (sqlfluff, yamlfmt, yamllint) passes.ol-dbt local register --database ol_warehouse_production_dimensional). These checks ran on DuckDB. Neither model has been built on StarRocks yet. Results:mv_b2b_learner_enrollment: 37,188 rows, all unique on (contract_pk, courserun_pk, user_pk). 0 nulluser_global_id, 0 nullrecord_updated_on. 21 distinctsso_organization_ids, 66 contracts.mv_b2b_enrollment_completion_funnel: 1,184 / 1,184 (contract, run) rows match on enrolled, active, passing and certified learner counts.mv_b2b_learner: 16,976 rows, unique on (org, user), 0 nulluser_global_id. Membership: 10,622 both, 45 enrollment only, 6,309 roster only.courses_enrolledagrees with the enrollment view's active-run count for 10,667 / 10,667 enrolled (org, learner) pairs. 0 rows have passed or certified above enrolled, and 0 roster-only rows have courses.mv_b2b_learner.record_updated_onis null for exactly the 6,309 roster-only learners and for none of the others. 663 learners hold a counted program certificate.starrocks_dbt_assetsrun creates both MVs. Confirm withDESC b2b_learner_records.mv_b2b_learner_enrollmentandDESC b2b_learner_records.mv_b2b_learner.Additional Context
Findings from those checks that the API tenant has to handle:
sso_organization_id: their org has no Keycloak UUID, so an API filtering on it can't reach them. The existing MVs have the same gap.is_passingfalse.completion_statushas to treat an unrevoked certificate ascertifiedwithout requiringis_passing.Checklist:
b2b_learner_recordsdatabase and itsappgrants) before merging🤖 Generated with Claude Code
https://claude.ai/code/session_017FmjwANjfgtuKAtsRPf6Ly