Skip to content

fix: scope external course readable_id to the vendor - #4090

Merged
AhtishamShahid merged 2 commits into
masterfrom
ahtisham/hq-13223-external-course-readable-id-collision
Sep 18, 2026
Merged

AhtishamShahid merged 2 commits into
masterfrom
ahtisham/hq-13223-external-course-readable-id-collision

Conversation

@AhtishamShahid

@AhtishamShahid AhtishamShahid commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

Fixes mitodl/hq#13223 (Sentry XPRO-8EF).

Description (What does it do?)

External courses from different vendors could receive the same readable ID because the vendor prefix was removed. For example, MXP-MCPO and MO-MCPO both became course-v1:xPRO+MCPO, causing a database error that stopped the remaining courses for that vendor from syncing.

  • Keeps the vendor prefix in new course IDs, such as course-v1:xPRO+MO_MCPO, and replaces dots and hyphens with underscores.
  • Rolls back a course that encounters a database integrity error, records it in the sync email, and continues syncing the remaining courses.
  • Preserves existing course IDs and URLs; no data migration is needed.

How can this be tested?

Regression tests cover distinct IDs for different vendors, course-code normalization, syncing alongside an existing course with a legacy ID, and continuing after an integrity error.

Validation completed:

  • 715 tests passed across courses/, cms/, and mail/.
  • Migration checks and Ruff lint/format checks passed.
  • Local sync with both vendors confirmed that an existing course-v1:xPRO+MCPO course and a new course-v1:xPRO+MO_MCPO course coexist, and both URLs return HTTP 200.

To validate, run uv run pytest courses/sync_external_courses/external_course_sync_api_test.py. For a manual check, sync two vendors with the same course tag and confirm they create separate courses. Trigger a course ID collision and confirm the remaining courses sync and the failed course appears in the sync email.

Additional Context

New course URLs include the vendor prefix. Courses with the same tag from different vendors remain separate catalog entries; whether they should appear as one offering is a separate product decision.

Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
`Course.readable_id` for an external course was generated from only the tag
half of the vendor course code (`<MXP | MO>-<COURSE_TAG>`), discarding the
prefix that identifies the vendor. Since `readable_id` is unique across all
courses, two vendors offering the same course tag produced the same readable
ID: Global Alumni's `MXP-MCPO` and Emeritus' `MO-MCPO` both mapped to
`course-v1:xPRO+MCPO`.

The course lookup is scoped by `(external_course_id, platform, is_external)`,
so it correctly missed the other vendor's row and tried to INSERT, which then
failed on `courses_course_readable_id_23dff66f_uniq`. This is deterministic,
not a race: it fails permanently for any course tag that arrives from a second
vendor.

Keep the vendor prefix in the readable ID (`course-v1:xPRO+MO_MCPO`). The
prefix is already in the payload, so no vendor mapping has to be maintained
and a third vendor needs no code change. `readable_id` is only written in
`get_or_create(defaults=...)`, so existing courses keep their IDs and every
`/courses/<readable_id>/` URL that resolves today keeps resolving. This also
fixes the collision on the derived `CourseRun.courseware_id`, and stops
`split("-")[1]` silently truncating tags that contain a dash.
@AhtishamShahid
AhtishamShahid force-pushed the ahtisham/hq-13223-external-course-readable-id-collision branch from 7df80f4 to 60a4c47 Compare September 10, 2026 08:24
Comment thread courses/sync_external_courses/external_course_sync_api.py Fixed
@AhtishamShahid
AhtishamShahid force-pushed the ahtisham/hq-13223-external-course-readable-id-collision branch from 60a4c47 to 1d0fbd0 Compare September 10, 2026 08:54
An `IntegrityError` on one course escaped the per-course loop up to
`task_sync_external_course_runs`, where `except Exception` sits inside the
per-platform loop. So a single bad course abandoned the rest of that vendor's
catalog, and `send_external_data_sync_email` never ran: the ops stats email
for the vendor silently disappeared, leaving Sentry as the only signal.

Wrap each course in `atomic_or_record_failure`, which rolls the course back,
logs it and records it in a new `courses_failed` stat surfaced in the sync
email. Implemented as a context manager replacing `transaction.atomic()` at
the call site so the per-course block keeps its original indentation, which
keeps the diff to a single changed line and avoids re-surfacing unrelated
static-analysis findings on untouched log statements.

The `except` sits outside `transaction.atomic()`, so the failed course is
rolled back cleanly before it is recorded and the loop continues.
@AhtishamShahid
AhtishamShahid force-pushed the ahtisham/hq-13223-external-course-readable-id-collision branch from 1d0fbd0 to 5cb44d3 Compare September 10, 2026 09:25
@arslanashraf7

Copy link
Copy Markdown
Contributor

@AhtishamShahid could you comment for @cachob's review telling:

  1. What was the previous course ID generation?
  2. What is the new course ID generation format?
  3. Also, we are not updating the historical course IDs, so in the future we are going to be living with two different course ID formats for external courses.
  4. Specifically, I am interested in whether this could have any backlash from accounts/analytics or any other teams.

I want to get Bon's approval on this before we proceed next.

@AhtishamShahid

AhtishamShahid commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

@AhtishamShahid could you comment for @cachob's review telling:

  1. What was the previous course ID generation?
  2. What is the new course ID generation format?
  3. Also, we are not updating the historical course IDs, so in the future we are going to be living with two different course ID formats for external courses.
  4. Specifically, I am interested in whether this could have any backlash from accounts/analytics or any other teams.

I want to get Bon's approval on this before we proceed next.

  1. Old format — we kept only the part after the first -, dropping the piece that identifies the vendor:
    • MO-MCPO (Emeritus) → course-v1:xPRO+MCPO
    • MXP-MCPO (Global Alumni) → course-v1:xPRO+MCPO
  2. New format — keep the whole vendor code, replacing - and . with _ (not allowed in a URL path):
    • MO-MCPO → course-v1:xPRO+MO_MCPO
    • MXP-MCPO → course-v1:xPRO+MXP_MCPO
      Run IDs follow: course-v1:xPRO+MO_MCPO+99-05-1. The prefix comes from data the vendors already send, so a third vendor needs no code change.
  3. Yes, intentionally not backfilling older data that already exists, as it will change URLs that can impact certificates and receipts, emails, etc.
  4. I cannot find couse_id usage anywhere, so I guess it should be pretty much benign, and there is URL to navigate to the vendor's page, and it is saved in db that is not manually generated.

FYI @cachob

@cachob

cachob commented Sep 16, 2026

Copy link
Copy Markdown

@asadali145 - If I remember it correctly you were involved when we worked on the api integration for the xPRO external courses. I remember that we put in some logic and reason as to why we created the current naming convention. Will this proposed PR create any issues?

@asadali145 asadali145 self-assigned this Sep 17, 2026
@asadali145

Copy link
Copy Markdown
Contributor

@asadali145 - If I remember it correctly you were involved when we worked on the api integration for the xPRO external courses. I remember that we put in some logic and reason as to why we created the current naming convention. Will this proposed PR create any issues?

Hi @cachob, yes, I worked on it. I don't recall any specific reason for omitting the platform code from the Run ID; The platform code is MO for Emeritus and MXP for Global Alumni, and I don't think we knew this initially.

I have reviewed the changes in this PR, and it looks good to me.

@asadali145 asadali145 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.

LGTM!

@AhtishamShahid
AhtishamShahid merged commit 599a468 into master Sep 18, 2026
8 checks passed
@AhtishamShahid
AhtishamShahid deleted the ahtisham/hq-13223-external-course-readable-id-collision branch September 18, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants