Skip to content

fix: course cert not created on credentials service#38774

Open
Waleed-Mujahid wants to merge 1 commit into
openedx:masterfrom
edly-io:fix/credentials-course-cert-creation
Open

fix: course cert not created on credentials service#38774
Waleed-Mujahid wants to merge 1 commit into
openedx:masterfrom
edly-io:fix/credentials-course-cert-creation

Conversation

@Waleed-Mujahid

@Waleed-Mujahid Waleed-Mujahid commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes

#38776

Root Cause

COURSE_CERT_CHANGED fires synchronously inside GeneratedCertificate.save(), which itself runs inside the generate_certificate Celery task. The .delay() call was enqueuing award_course_certificate before the DB transaction committed, so a second Celery worker picked up the task immediately, called eligible_certificates.get(), found nothing (the row wasn't committed yet), and returned silently — no retry, no error, no course cert posted to Credentials.

The race window is small (~30ms in observed logs) but consistent, and reproducible on every fresh course completion:

10:09:52.540  award_course_certificate RUNNING   → eligible_certificates.get() → DoesNotExist → returns
10:09:52.572  generate_certificate commits cert as downloadable                  ← 32ms too late

Impact

Course certificates are never synced to the Credentials IDA, even when:

  • CredentialsApiConfig.enable_learner_issuance is True
  • ENABLE_LEARNER_RECORDS is True
  • The learner has a valid downloadable GeneratedCertificate in the LMS

The eligible_certificates manager only excludes audit_passing / audit_notpassing statuses — mode (verified, no-id-professional, etc.) is irrelevant. The cert is perfectly eligible; it simply does not exist in the DB at query time.

Program certificates are unaffectedhandle_course_cert_awarded (the program cert handler) does not query eligible_certificates; it delegates to get_completed_programs which reads from the Credentials API directly.

Downstream breakage: Learner Record MFE

The Learner Record MFE (frontend-app-learner-record) renders program record pages by querying the Credentials service for both the program UserCredential and individual course-run UserCredentials. When course certs are never posted to Credentials, the MFE shows every course in the program as "Not Earned" — even for learners who have fully completed all courses and hold a program certificate.

Example (observed):

Course Grade Status shown in MFE
Paid Course 1 Pass ❌ Not Earned
Paid Course 2 Pass ❌ Not Earned
Paid Course 3 Pass ❌ Not Earned

Program certificate: ✅ Awarded — making the "Not Earned" course rows even more confusing to learners.

The only workaround is the notify_credentials management command backfill, which calls award_course_certificate directly (bypassing the signal handler and therefore bypassing the race), but this needs to be run manually after the fact for each affected learner.

Fix

Wrap the .delay() call in transaction.on_commit() so award_course_certificate is only enqueued after the generate_certificate transaction commits — guaranteeing the GeneratedCertificate row exists in the DB when the task runs.

# before
award_course_certificate.delay(user.username, str(course_key))

# after
transaction.on_commit(lambda: award_course_certificate.delay(user.username, str(course_key)))

Testing

  1. Enrol a new learner (no prior certs) in a course that is part of a program
  2. Complete all graded content so the course grade passes for the first time
  3. Check Celery worker logs — confirm award_course_certificate log shows "will award a course certificate to user X in course run Y" (the line logged just before the credentials POST, at tasks.py)
  4. Verify a UserCredential of type course-run is created in the Credentials service admin for that learner
  5. Open the Learner Record MFE for the program — confirm the course shows Earned with grade and date populated
  6. Confirm no regression on program certificate issuance

Notes

  • The notify_credentials management command backfill remains the correct remediation for learners affected before this fix is deployed.
  • handle_course_cert_revoked dispatches revoke_program_certificates (program-level, not course-level) so is not affected by this race.
  • CertificateDateOverride signal handlers already use transaction.on_commit — this fix makes handle_course_cert_changed consistent with that pattern.

@Waleed-Mujahid Waleed-Mujahid changed the title fix: wrap award_course_certificate.delay in transaction.on_commit fix: course cert not created on credentials service Jun 17, 2026
@UsamaSadiq UsamaSadiq requested a review from Copilot June 18, 2026 07:15

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.

Pull request overview

Fixes a race condition in course certificate sync to the Credentials service by ensuring the Celery task is enqueued only after the certificate transaction commits, preventing workers from querying an uncommitted GeneratedCertificate row.

Changes:

  • Wrap award_course_certificate.delay(...) in transaction.on_commit(...) in the COURSE_CERT_CHANGED signal handler.
  • Update existing signal-handler tests to account for on_commit being used (via mocking).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
openedx/core/djangoapps/programs/signals.py Enqueue the course-cert sync task on DB commit to avoid pre-commit race conditions.
openedx/core/djangoapps/programs/tests/test_signals.py Adjust tests by mocking transaction.on_commit (needs stronger assertions to actually validate the new behavior).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread openedx/core/djangoapps/programs/tests/test_signals.py
Comment thread openedx/core/djangoapps/programs/tests/test_signals.py
@Waleed-Mujahid Waleed-Mujahid force-pushed the fix/credentials-course-cert-creation branch 3 times, most recently from 5e5495c to eea62de Compare June 18, 2026 07:51
COURSE_CERT_CHANGED fires synchronously during GeneratedCertificate.save(),
which runs inside the generate_certificate Celery task. The .delay() call
was enqueuing award_course_certificate before the DB transaction committed,
so the task raced ahead and hit eligible_certificates.get() DoesNotExist —
exiting silently with no retry, and never posting the course cert to
Credentials.

Wrapping in transaction.on_commit() guarantees the cert row is committed
before the task is enqueued.

Fixes: EDLYPRODUCT-5411
@Waleed-Mujahid Waleed-Mujahid force-pushed the fix/credentials-course-cert-creation branch from 36422da to e619aef Compare June 19, 2026 06:16
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.

2 participants