Skip to content

Return per-status counts on the learner-progress envelope #67

Description

@blarghmatey

User Story

  • As a B2B organization manager, I want the learner directory's summary tiles to load without five round-trips, so the page does not fall over when a few managers open it at once.

Description/Context

MIT Learn's learner directory (mitodl/mit-learn#3958) renders four summary tiles above the table: Enrollments, Not started, In progress, Completed. LearnerProgressResponse carries total_count and outcomes_withheld_count but no per-status breakdown, so the page gets the other three by issuing three more requests to /learner-progress with limit=1 and a completion_status filter, reading only total_count off each and discarding the row.

That is five requests per page load, and each one is more expensive than the limit=1 suggests:

Per request Cost
require_org_manager mitxonline_client.is_org_manager — TTLCache, 60s, no per-key lock, so concurrent cold-cache misses all round-trip
require_contract_in_org one existence check against mv_b2b_contract_utilization
latest_refresh_timestamp cached 300s per (schema, MV), so effectively free
page query SELECT ... LIMIT 1 over mv_b2b_learner_enrollment
count query COUNT(*) plus the withheld SUM over the same set — full scan regardless of limit

So one page load is roughly 15 StarRocks queries and, on a cold manager cache, five concurrent authenticated round-trips to MITx Online. starrocks_pool_max_size is 10 per pod and starrocks_pool_acquire_timeout_seconds is 5, and a saturated pool is a 503 through add_shared_error_handlers — which the dashboard renders as "Something went wrong loading learner data." Two managers opening the page at the same moment is enough to reach that, and the four extra requests buy three integers.

outcomes_withheld_count already exists on this envelope for exactly this reason: so a client can characterize the result set without paging through it. The status breakdown belongs in the same place.

The count query already aggregates over the filtered set, so conditional SUMs per status add no scan. The shape is the cheap part; the question below is not.

Open question, and it is a product decision rather than an implementation one. MIT Learn's tiles today are deliberately unfiltered — a fixed summary of the contract while the table narrows under search and the status filter. Counts computed in the existing count query share that query's WHERE clause, so they would narrow with it instead. Either:

  • the counts track the response's own filters (free, consistent with total_count and outcomes_withheld_count, and the tiles come to mean "of the rows matching your filters"), or
  • the tiles stay a fixed contract summary, which needs the counts computed over scope-only predicates (organization, contract, include_inactive) and therefore a second aggregate in the same request.

I lean toward the first: it costs nothing, it keeps every number in the envelope describing one result set, and a tile that disagrees with the table underneath it is the confusing case. Either way it collapses to one request. This needs a call from whoever owns the dashboard's UX before implementation.

Related, not in scope: mitxonline_client's _cache has no per-key lock, unlike refresh_metadata, so N concurrent cold-cache requests for one (sub, org) all issue the manager check. Fixing the fan-out here makes that much less likely to bite, but it is worth its own issue.

Acceptance Criteria

  • LearnerProgressResponse carries a per-status count for each CompletionStatus value
  • Consent-withheld rows are counted in no status bucket, since the API nulls their completion_status; the buckets plus outcomes_withheld_count sum to total_count
  • The counts add no additional scan of mv_b2b_learner_enrollment beyond the existing count query
  • Whether the counts are filter-scoped or contract-scoped is decided and documented in the field descriptions, which are read by managers
  • feat: add B2B contract learner directory page mit-learn#3958's tiles drop to a single request

Plan/Design

Extend the count query in learner_queries.learner_progress with one conditional SUM per status, alongside the outcomes_withheld_count SUM already there, and add the field to LearnerProgressResponse in learner_models.py. passed and certified are mutually exclusive in _COMPLETION_STATUS (an unrevoked certificate is checked first), so the buckets do not overlap.

A nested object rather than four sibling fields, so the set stays closed when a status is added:

{
  "organization_id": "...",
  "as_of": "...",
  "total_count": 168,
  "outcomes_withheld_count": 0,
  "completion_status_counts": {
    "not_started": 14,
    "in_progress": 125,
    "passed": 8,
    "certified": 21
  },
  "data": [...]
}

Note for the consumer: passed and certified are separate buckets here, and a client that wants one "Completed" number sums them. mitodl/mit-learn#3958 currently counts ["passed", "certified"] for its Completed tile while its Completed filter sends only passed, which is the kind of mismatch a single explicit breakdown makes hard to write.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions