Skip to content

Track contract run edX clones and make the clone safe to retry (C3 1/3) - #3963

Merged
blarghmatey merged 2 commits into
mainfrom
b2b-c3-clone-status
Sep 25, 2026
Merged

blarghmatey merged 2 commits into
mainfrom
b2b-c3-clone-status

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

What are the relevant tickets?

Capability C3 of the B2B onboarding RFC, https://github.com/mitodl/hq/discussions/12784. Design and decisions: https://github.com/mitodl/hq/discussions/12784#discussioncomment-18437846.

Stack (1 of 3). Based on main. 2/3 moves contract setup out of the management commands. 3/3 adds the staff contract API, which reads the record added here.

Description (What does it do?)

A contract run's edX clone runs in Celery (clone_courserun). When it exhausted its retries, the MITx Online run and product stayed behind with no course in edX. The task logged the exception, but nothing in MITx Online recorded the failure. The staff contract API reports and retries clones, so the state has to be stored somewhere.

  • CourseRunClone (openedx/models.py, migration 0012_add_course_run_clone): one row per course run with status (pending, cloning, cloned, failed), attempts, clone_requested_at and the last error.
  • Written by clone_courserun itself (get_or_create), so clones queued by the management commands, the Wagtail program inline or the API are all tracked. The error is recorded before each retry. The status becomes failed when retries run out, or on an exception that isn't retried. create_contract_run creates the pending row before queueing, so a run has a status before a worker picks the task up.
  • The clone is safe to retry. process_course_run_clone raised ValueError whenever the target already existed in edX, and ValueError isn't retried, so a retry after edX had accepted the clone failed permanently. It now stamps clone_requested_at just before asking edX to clone. An existing target counts as this run's only when the stamp is set: the clone call is skipped and run data and modes are still pushed. Without the stamp an existing target still raises, so a real key collision fails as before.

courses/api.py calls process_course_run_clone directly, without a record, and is unchanged. Runs cloned before this deploys have no record. Setup status (3/3) shows them with no clone status rather than as pending.

How can this be tested?

Run in the web container: pytest openedx/tasks_test.py openedx/api_test.py b2b/api_test.py b2b/models_test.py b2b/management/tests courses/api_test.py, 615 passed and 7 skipped. makemigrations --check reports no changes. pre-commit passes.

New tests:

  • openedx/tasks_test.py: the record through a retry, retry exhaustion, a non-retried error, and success with and without a pre-created row
  • openedx/api_test.py: an existing target is accepted only with the stamp, and the stamp is written before the clone call
  • b2b/api_test.py: create_contract_run creates the pending row

Not run: a clone against a real edX. With edX running locally, b2b_courseware add <contract> <course readable_id> should leave a CourseRunClone row for the new run that moves to cloned.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy

@github-actions

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## Changes for v0.yaml:
No changes detected

## Changes for v1.yaml:
No changes detected

## Changes for v2.yaml:
No changes detected

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Copilot AI 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.

🟡 Changes recommended

Target lookup errors and concurrent task deliveries can still misclassify or duplicate clones.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds persistent tracking and retry safety for B2B Open edX course-run cloning.

Changes:

  • Adds CourseRunClone status, attempt, timestamp, and error tracking.
  • Makes clone retries resume when the target was previously requested.
  • Creates pending tracking records before queuing contract clones.
File summaries
File Description
openedx/tasks.py Tracks clone attempts and outcomes.
openedx/tasks_test.py Tests task status transitions.
openedx/models.py Defines clone tracking model.
openedx/migrations/0012_add_course_run_clone.py Creates tracking table.
openedx/constants.py Defines clone statuses.
openedx/api.py Adds retry-aware cloning behavior.
openedx/api_test.py Tests collision and request-stamp behavior.
b2b/api.py Creates pending records before queueing.
b2b/api_test.py Verifies pending-record creation.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread openedx/tasks.py
Comment thread openedx/api.py Outdated

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

Seems fine - tests check out and read-through made sense. I lack a working edX integration at the moment but the touchpoints for this also don't really change.

Comment thread b2b/api.py
blarghmatey and others added 2 commits September 25, 2026 12:38
A contract run whose edX clone exhausted its retries left a local run and
product with no course behind it, and nothing recorded that. The staff
contract API needs to report and retry that, and runs created by the
management commands or Wagtail need it too, so the record is written by
clone_courserun itself rather than by any one caller.

process_course_run_clone raised ValueError whenever the target already
existed in edX, and ValueError is not retried. A retry after edX had
accepted the clone therefore failed permanently. The clone record now
stamps clone_requested_at before asking edX to clone, and an existing
target is accepted only when that stamp is set, so a real key collision
still fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy
…rget

clone_courserun is acks_late, so a redelivered message could run two
attempts for one run at once. Both could see the target absent and ask
edX to clone, and the losing attempt, holding a record loaded before the
winner stamped clone_requested_at, could mark the run failed after it
succeeded. Attempts now take a per-run cache lock, the same pattern
create_program_contract_runs uses. A delivery that finds the lock held
leaves the record alone, and the record is loaded only after the lock is
taken. start_attempt increments attempts with F().

process_course_run_clone treated every CourseRunAPIError from the target
lookup as "not in edX". edx_api raises it for any HTTP error, so a 401 or
5xx would stamp the record and send a clone request, and a later retry
would then accept an existing course as this run's clone. Only a 404 on
the wrapped HTTPError now means absent. Anything else is re-raised and
retried by the task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy
@blarghmatey
blarghmatey merged commit 94e4e7e into main Sep 25, 2026
15 checks passed
@blarghmatey
blarghmatey deleted the b2b-c3-clone-status branch September 25, 2026 16:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants