Skip to content

feat: add endpoint to delete or archive a CompetencyCriterion (#674) - #879

Open
ufedaseyeuconsultant wants to merge 12 commits into
openedx:mainfrom
ufedaseyeuconsultant:ufedaseyeu/674-delete-competency-criterion
Open

ufedaseyeuconsultant wants to merge 12 commits into
openedx:mainfrom
ufedaseyeuconsultant:ufedaseyeu/674-delete-competency-criterion

Conversation

@ufedaseyeuconsultant

Copy link
Copy Markdown
Contributor

Description

Adds a DELETE endpoint that removes a CompetencyCriterion (a gradeable-subsection
association within a Competency Criteria tree): hard-deletes it when no learner mastery status
references it, or archives it in place (archived=True) when one does, so learner progress
already recorded against that criterion stays intact and auditable.

On the hard-delete path, the criterion's ObjectTag is untagged via the same read-merge-write
tag_object() call create_competency_criterion() already uses to tag it, but only once no
other CompetencyCriterion still shares that ObjectTag (the same tagged assignment can be used
in two different groups). The archive path never touches the ObjectTag.

Removing or archiving a criterion can leave its parent CompetencyCriteriaGroup, and potentially
several ancestor groups above it, with no remaining active content. This endpoint walks the
ancestor chain and hard-deletes or archives each level in turn (mirroring the same
learner-status rule), so no empty group is ever left persisted, per
ADR-0002 Decision 2. The
delete-vs-archive choice, for both the criterion and any cascaded ancestor group, is a
server-side detail driven entirely by learner-status presence; the DELETE verb covers both
outcomes; see the ADR-0002 Decision 7 delete-protection rule this implements.

This change impacts the Course Author / Platform Administrator roles (correcting or
retiring a mastery rule). It is a backend API addition only; no Studio UI consumes it yet (the
in-use warning/confirmation flow this enables is a separate, Studio-side ticket).

Supporting information

Testing instructions

  1. pip install -e . (or your usual dev setup), then from the repo root:
    DJANGO_SETTINGS_MODULE=test_settings pytest --no-cov tests/openedx_learning/applets/cbe/
    
  2. Or manually, via Postman/curl against a running instance, mirroring the ticket's acceptance
    criteria:
    • POST /api/cbe/v1/competencies/<tag_id>/criteria/ to create a criterion (see [BE] Create CompetencyCriteria #665).
    • DELETE /api/cbe/v1/competencies/<tag_id>/criteria/<criterion_id>/ on it:
      • No learner status recorded against it → 200 {"id": ..., "archived": false}, row gone.
      • A StudentCompetencyCriteriaStatus row recorded against it → 200 {"id": ..., "archived": true},
        row still present with archived=true.
      • Repeating the same DELETE on an archived row → same 200 response again.
      • Repeating the same DELETE on an already hard-deleted row → 404.
      • A mismatched tag_id, or an unknown criterion_id → 404.
      • Without oel_tagging.can_tag_object for the criterion's course → 403.

Other information

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Oct 16, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @ufedaseyeuconsultant!

This repository is currently maintained by @axim-engineering.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

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

See inline comments.

assert ObjectTag.objects.filter(id=object_tag.id).exists()


def test_status_on_the_criterion_missed_by_the_first_check_archives_it(

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.

what real situation does this test stand for?

assert criterion.archived is True
assert ObjectTag.objects.filter(id=object_tag.id).exists()


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.

Thanks for covering the fallback path with tests. I'm having trouble working out which real concurrency situations these tests stand for. Could you add a short explanation, either in the PR description or in the docstrings of delete_competency_criterion and these tests? It would help to cover:

Which races can actually happen here. For example: a grade write adds a status row on the criterion, or on an ancestor group, between the status check and the delete. For each race, which concurrent operation causes it?
Which of those races the criterion row lock at api.py:520 already prevents. If the lock blocks a concurrent status insert on the criterion, can test_status_on_the_criterion_missed_by_the_first_check_archives_it still happen in practice, or is that path only defensive?
How each test maps to one of those races. The tests force a stale check with side_effect=[False, True], so it's hard to tell from the tests alone which real interleaving each one stands for.
A sentence or two per race would be plenty. Thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring of delete_or_archive_competency_criterion now lists them. There are two: a concurrent create for the same competency, which now waits on the Tag row lock, and a learner-status insert on the criterion or on one of its ancestor groups, which waits on that row's lock. Both are prevented by the locks, so the side-effect tests that simulated a stale check are removed.

user, tag: Tag, object_tag: ObjectTag, default_rule_profile: CompetencyRuleProfile, now: datetime,
) -> None:
"""
The leaf group has a status row, but the cascade's first check misses it, so the group delete is

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.

Can you explain what "the cascade's first check" is?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was the ancestor-group status check inside _cascade_from_group on the first of two attempts. The retry is gone, so there is only one check, made while the ancestor groups are locked. The test is removed.

Comment on lines +139 to +140
An IntegrityError from the object_tag cleanup, with no status anywhere, is retried once and then
propagates. The criterion is not archived and its group is unchanged.

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.

Can you explain why? what is the reasoning for the retry and propagate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry existed to recover from an ancestor gaining a status between the cascade's check and its delete. Locking the ancestor groups closes that window, so the retry is removed, and any integrity error now propagates on the first attempt.

)
if not request.user.has_perm("oel_tagging.can_tag_object", perm_obj):
raise PermissionDenied()
result = delete_competency_criterion(criterion.id)

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.

Maybe name the method delete_or_archive_competency_criterion?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed

Comment thread src/openedx_learning/applets/cbe/api.py Outdated
# status on the criterion itself justifies archiving it.
with transaction.atomic():
criterion = CompetencyCriterion.objects.select_for_update().get(pk=criterion_id)
if _criterion_has_status(criterion_id):

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.

Could we find a better term than status? it's hard to tell what that refers to. Maybe if _criterion_has_learner_progress?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed

group.criteria.filter(archived=False).exists() or group.child_groups.filter(archived=False).exists()
)
parent = group.parent
if has_live_child:

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.

Great catch.

@jesperhodge

jesperhodge commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Here's a pretty in-depth AI review. The points it makes sound right to me.

Review of PR #879: delete or archive a CompetencyCriterion

Review Details

Prerequisites, deferred items, what passed, and the action list

Prerequisites (skip if you know the codebase)

The models. A CompetencyCriteriaGroup is a node in a per-competency tree: a root group per tag, a course-level group per course under the root, and leaf groups under that. Its parent foreign key points upward (child_groups is the reverse name). A CompetencyCriterion hangs off a leaf group (group, reverse name criteria) and points at an ObjectTag (object_tag), the tagging-system row that says "this subsection carries this competency tag". Both the group and the criterion have an archived boolean (added by #716). Two learner-status models exist: StudentCompetencyCriterionStatus references a criterion, and StudentCompetencyCriteriaGroupStatus references a group. Both use on_delete=PROTECT, so the database refuses to delete a row they point at. By contrast CompetencyCriteriaGroup.parent, CompetencyCriterion.group, and CompetencyCriterion.object_tag are all CASCADE, which Django emulates in Python by re-querying the children at delete time. Ticket #674 calls the criterion-status model StudentCompetencyCriteriaStatus; the class on main is StudentCompetencyCriterionStatus.

What the change adds. delete_competency_criterion(criterion_id) in the applet's api.py opens a transaction, locks the criterion row with select_for_update(), and either archives it (if a status row references it) or deletes it. On the delete branch it also removes the ObjectTag through the tagging API's tag_object() read-merge-write, but only when no other criterion still references that ObjectTag. It then walks up the group tree (_cascade_from_group), deleting or archiving each ancestor left with no live child. If the database refuses the delete (ProtectedError or IntegrityError), the function re-checks the criterion's status, archives it if one exists, and otherwise retries the whole attempt once. A new CompetencyCriterionDeleteView at DELETE /api/cbe/v1/competencies/<tag_id>/criteria/<criterion_id>/ resolves the row, checks oel_tagging.can_tag_object inline, calls the function, and returns 200 {"id", "archived"}. The shared CompetencyCriterionSerializer gains a read-only archived field.

Names the tickets coined. "Cascade" or "the walk" is the ancestor loop just described. An "archive event" versus a "delete event" is the mode the walk runs in: after an archive it only archives; after a delete it deletes a group that is fully empty and archives one whose only remaining children are archived. "Delete protection" is ADR-0002 Decision 7: a row referenced by learner status is archived instead of deleted.

The ADRs cited (in docs/openedx_learning/decisions/): ADR-0002 (competency criteria model, including Decision 2, the rule that no empty group persists, and Decision 7, delete protection), ADR-0003 (competency criteria versioning), ADR-0004 (competency mastery concurrency, which covers the grade write).

Database terms (unsourced, general knowledge). select_for_update() takes a row lock until the transaction ends. ATOMIC_REQUESTS is the Django setting that wraps each request in one transaction. READ COMMITTED and REPEATABLE READ are transaction isolation levels: the first lets a query see rows other transactions committed meanwhile, the second reads a fixed snapshot. django-simple-history writes a history row on every save(), and _history_user names the user on that row.

The tickets around this one. #674 is this ticket. #665 built the create endpoint and the associate_competency_criterion function this PR mirrors. #716 added the archived fields. #675 (open) will delete or archive a whole group directly and expects to reuse this PR's cascade helper. #709 and #710 (open, frontend) will call this endpoint; #710 names the URL shape DELETE .../competencies/<tag_id>/criteria/<criterion_id>/ and expects a removed subsection to be "selectable again" afterwards. #874 landed on main after this branch was cut: it adds a bulk-update endpoint that takes user, checks permission inside the API function, locks groups before criteria, and refuses with a 409 any edit to criteria under an archived group or archived ancestor.

How the endpoint reaches Studio. edx-platform's cms/urls.py includes openedx_learning.urls under api/, which includes this applet's rest_api/v1/urls.py under cbe/. The permission rule lives in edx-platform's content_tagging/rules.py and derives the course from whatever key string it is given, so a usage key works.

How the review tested the tests. For each acceptance criterion the review broke the code path on purpose (for example, forced the status check to return False, or made the cascade return immediately), ran the two new test files, and checked that at least one test failed for that reason. A break that left all 68 tests green means the behavior is untested.

Things the review looked at and is leaving to other tickets

What passed

At the head commit, the two touched test files pass (68 tests), pylint on the six touched files is clean, mypy on the four source files is clean, lint-imports keeps all three contracts, and makemigrations --check reports no changes, both at head and on the merged tree. Every acceptance criterion except the two test gaps below has a test that fails when its behavior is broken. The permission object is built from stored data rather than from the URL so a caller cannot reach another course's criterion through a tag they can view, the route is mounted in Studio through the existing include, and the URL shape matches what #710 will call. The review could not run anything that depends on row locks, because the test database is SQLite.

Action list (in priority order)

  1. Rebase onto main; resolve the four additive conflicts; add archived to feat: add bulk update competency criteria endpoint #874's CRITERION_FIELDS; mention the bulk-update response change in the PR description.
  2. Decide and record how archived groups come back, and whether the duplicate check should ignore archived criteria now; state the limitation in the PR description.
  3. Move the permission check into delete_competency_criterion with a user argument and set _history_user, or record the deviation from [BE] Delete Competency Criterion #674.
  4. Lock the Tag row before the criterion, as the create path does; consider locking ancestor groups and dropping the retry.
  5. Untag only when the criterion's ObjectTag is among the rows get_object_tags() returned.
  6. Map CompetencyCriterion.DoesNotExist to NotFound in the view.
  7. Make the test changes described in the test findings and in the depth-2 improvement.
  8. Fix the two docstrings, and update the PR description (deviations section, cherry-pick paragraph, model name, how to create a status row).
  9. Record the two ADR tensions (group-status check, live ObjectTag on archive), settle the grading-write and PostgreSQL questions, and decide the remaining items under "Decisions to make or write down".
  10. Do the remaining improvements and small things.

Must fix before merge

The branch no longer merges cleanly with main, and once merged it makes an existing test fail.

Details

The branch conflicts with main and breaks a bulk-update test. Merging the head onto current main conflicts in the applet's api.py import block, urls.py, views.py (three places), and test_views.py (two places), all additive side-by-side insertions from this PR and from #874. After resolving them by keeping both sides, test_bulk_update_with_rule_values_echoes_every_criterion_as_stored fails: it compares the response keys to a fixed set, and the shared serializer now also emits archived. Rebase onto main, add archived to that test's expected key set, and say in the PR description that the bulk-update response now carries archived too. The migration check and the import linter pass on the merged tree.

Once this change archives a group, nothing in the system can bring it back, and other features get stuck on it.

Details

Nothing can ever un-archive a group, and the create path and bulk update both get stuck on archived groups. This PR is the first code that archives groups. Once the walk archives a course-level group (or the root group that every course shares for that tag), three existing pieces of code turn that into a dead end. The create path's _create_leaf_group reuses archived root and course-level groups through get_or_create, by design, and the unique constraint on (tag, course, parent) has no condition, so no live replacement course-level group can exist. The duplicate check _reject_duplicate_criterion counts archived criteria, so re-associating the same subsection with the same competency without a group_id is rejected as a duplicate, which contradicts #710's "selectable again" criterion. And #874's bulk_update_competency_criteria on main refuses with a 409 any edit under an archived group or ancestor. Put together: an author removes a criterion that has learner progress, then adds a different subsection to the same competency in the same course; the new criterion lands under the archived course-level group and can never be edited through the bulk-update endpoint. #675's open questions mention this reactivation problem and defer it to a follow-up ticket that does not exist. Before #709/#710 ship, decide and write down (an amendment to ADR-0002 Decision 2 or a follow-up ticket) that a container archived only because it emptied is reactivated when the create path adds a live child under it, and that the duplicate check ignores archived criteria. State the limitation in the PR description now.

The permission check happens only in the web endpoint and not in the delete function, which departs from the ticket and from how newer code on main does it.

Details

The permission check lives only in the view, and the function takes no user. Ticket #674 asked for delete_competency_criterion(criterion_id, user) with the can_tag_object check inside the function. The PR puts the check in CompetencyCriterionDeleteView.delete and the function takes only criterion_id. That matched associate_competency_criterion at the time, but #874 on main now takes user, checks permission inside bulk_update_competency_criteria, and sets _history_user on the rows it saves so the audit rows name who did it. After the rebase, the module's only destructive exported function would follow the older convention: any in-process caller can delete criteria unchecked, and the archive's history rows carry no user outside the CMS middleware. Take user, check inside the function, set _history_user on the rows the archive saves, and record the deviation from #674 either way. Also settle which key the permission object carries: the create view passes the subsection's usage key, #874 passes the course key; edx-platform's rule accepts both.

The delete does not take the same lock the create takes, so on some databases a create happening at the same moment can be silently deleted.

Details

The delete path skips the Tag-row lock the create path relies on, so a concurrent create can be silently deleted. associate_competency_criterion holds Tag.objects.select_for_update() across its duplicate check, its group get_or_create, and its tag_object() call. The delete path locks only the criterion row. Deleting a group or an ObjectTag cascades to whatever children exist at that moment. The failure depends on the database's isolation level, and the review could not run it (the test database is SQLite, which ignores row locks). On PostgreSQL, or MySQL under READ COMMITTED, a leaf group or criterion that another request committed between the walk's emptiness check and group.delete() is collected and deleted while that other request already returned 201. On MySQL's default REPEATABLE READ (edx-platform sets no isolation level) the collector's snapshot misses the new row, the database foreign key refuses the delete, and the retry runs and stops correctly, so the loss becomes a retry. A second association that reuses the ObjectTag being untagged is exposed the same way. Read the criterion's group.tag_id without a lock, lock the Tag row, then lock the criterion, matching the create path.

Untagging works out which tags to keep from a query that hides disabled-taxonomy tags but deletes through one that does not, so with the competency taxonomy disabled it can delete other competencies' tags on the same subsection, or fail with a protected-row error; only callers that bypass the web endpoint reach this.

Details

Untagging reads through a filtered query but writes through an unfiltered one. The delete branch builds the list of tag values to keep from get_object_tags(), which hides tags of disabled taxonomies and deleted tags, and hands it to tag_object(), which deletes every ObjectTag it finds that is not in the list, with no such filter. With the competency taxonomy disabled, the keep list is empty and every ObjectTag of that taxonomy on the object is deleted, cascading to the other competencies' criteria on that subsection; if one of those has a learner status the call raises ProtectedError instead. The review reproduced both outcomes by calling the function directly. The REST path is refused earlier (the permission rule requires an enabled taxonomy), so only in-process callers reach this. Untag only when the criterion's object_tag.id is among the rows get_object_tags() returned, and raise otherwise.

Sending the same delete request twice at nearly the same time can produce a server error instead of a not-found response.

Details

A concurrent repeat DELETE returns 500 instead of 404. The view resolves the criterion with get_object_or_404, then the function re-reads it with select_for_update() and raises CompetencyCriterion.DoesNotExist if it has gone. The view maps only Http404 and PermissionDenied; the create view maps DoesNotExist to NotFound. A double click on the remove control in #710 sends two requests; the second waits on the row lock and then raises. Catch CompetencyCriterion.DoesNotExist around the API call and raise NotFound.

Two tests meant to check where the ancestor cleanup stops do not fail when that rule breaks.

Details

The cascade's stop rule is tested only as "the row still exists". The two tests for "the walk stops at an ancestor with a surviving sibling" (test_single_child_leaf_group_cascade_deletes_while_a_sibling_keeps_the_root_alive and test_cascade_stops_at_an_ancestor_with_a_surviving_sibling_group) assert that the root still exists but not that it is still live. When the review forced the walk to treat every level as having no live child, the root with a live sibling was archived and all 68 tests passed. Add root.refresh_from_db() and assert root.archived is False in both, and the same for the sibling groups.

One behavior the ticket describes has no test that fails when it breaks.

Details

No test covers a hard delete whose group keeps only archived siblings. The ticket says that when a hard delete leaves a group with nothing but archived children, the group is archived and the walk continues upward as an archive. The code does this correctly (the review probed it), but when the review removed that branch, all 68 tests still passed. Add a service test with one archived criterion (with a status) and one live criterion in the same group, delete the live one, and assert the group and its parent end up archived.

The permission-denied test would still pass if the check ran too late, and one half of the permission rule has no test.

Details

The 403 test proves only the status code, and half of the permission is untested. test_delete_no_permission_is_403 denies the object-id half of the rule and asserts the 403. When the review moved the permission check to after the delete call, the test still passed, because the row was already gone by the time the 403 was raised and the test never looks. Assert afterwards that the criterion and its ObjectTag still exist. Separately, nothing exercises the taxonomy-view half of can_tag_object (the "lacks view access to the taxonomy" clause of the acceptance criterion); add a case where the taxonomy permission is denied and the object id is allowed.

The documented reason for the endpoint's URL shape is wrong, and the ticket is never told the URL differs from what it specified.

Details

The view docstring gives a false reason for the URL shape and the PR hides the change from the ticket. Ticket #674 specified criteria-groups/<group_id>/criteria/<criterion_id>/ with a group mismatch returning 404. The PR uses competencies/<tag_id>/criteria/<criterion_id>/ and the docstring says "a group-scoped delete is a separate ticket's concern (direct group deletion/archival)". #675, the group-deletion ticket, is itself tag-scoped, so that sentence is wrong. The tag-scoped shape is the better choice: #710 names exactly this URL and carries no group id. Say that in the docstring instead, list the deviation under a "Deviations from #674" heading in the PR description, and update #674.

The pull request description is out of date and uses a wrong class name.

Details

The PR description is stale. It says #613 "has not landed on main yet" and that the branch cherry-picks its migrations plus a renumbering commit. The PR has three commits, no migrations, and #613 landed on main before the branch point. It also names the criterion-status model StudentCompetencyCriteriaStatus (the class is StudentCompetencyCriterionStatus), and manual testing step 2 gives no way to create a status row. Delete the cherry-pick paragraph, fix the model name, say how to create a status (Django shell, admin, or record_graded_object_statuses), and add the deviations list (URL shape, permission placement, archived on the bulk-update response).

One docstring claims a rule that only the create endpoint enforces, not the data model.

Details

_cascade_from_group's docstring states an invariant no constraint enforces. It says "group.criteria only has rows at the true-leaf level". Only the create endpoint's resolve_supplied_leaf_group enforces that; ADR-0002 Decision 2 allows mixed groups, and the PR's own tests create criteria directly under root groups. The argument that actually carries correctness, that checking both relations at every level is harmless, is the second sentence. Drop the leaf claim and keep that sentence.

Decisions to make or write down

The implementation checks something an architecture decision says is not checked for this purpose, and which rule wins is unrecorded.

Details

Checking group status directly departs from ADR-0002 Decision 7. The ADR calls StudentCompetencyCriteriaGroupStatus a roll-up table "not independently checked for this purpose", because the roll-up can lag behind leaf progress. #674's acceptance criterion (AC12) requires the check, and the walk does it in _group_has_status. The foreign key is PROTECT anyway, so the check only turns a would-be database error into an archive. Record which rule wins, in one line on the PR, and ask a human to clarify the ADR.

After an archive the competency tag stays visible on the subsection, which differs from what an architecture decision describes.

Details

Leaving the ObjectTag live on the archive path is not what ADR-0003 Decision 3 describes. The ADR says that after a status exists, disassociation is archive-only and ordinary reads exclude archived rows; ObjectTag has no archived column, so the subsection keeps showing the competency tag in the tagging UI. #674 accepts this for the MVP. Record it as an ADR-0003 amendment or a follow-up ticket.

A hard delete that lands at the wrong moment can make a learner's grade save fail.

Details

A hard delete can now fail a learner's grade write. record_graded_object_statuses reads the live criteria for a graded object, then inserts status rows. A criterion that no learner has a status on yet, hard-deleted between that read and that insert, makes the insert fail its foreign key inside the grade's transaction. The window is small, but the failure lands in grading. Ask the owner of ADR-0004 whether the leaf-status write should skip a criterion that has disappeared.

Answering not-found before checking permission lets an unauthorized caller learn which criterion ids exist.

Details

404 before 403 lets an unauthorized caller enumerate criterion ids under a tag. The view looks the row up before checking permission, so a caller without rights learns which (tag, criterion) pairs exist by walking sequential ids. The create view discloses only whether a tag exists. Decide whether to check permission against the tag's taxonomy before the lookup.

One test covers a situation that no supported input can produce.

Details

One test pins a path no supported input reaches. test_unrelated_integrity_error_propagates_without_archiving injects an IntegrityError from tag_object and asserts the call count is 2. Keep it only if "retry exactly once" is a deliberate contract, and say in its docstring that the error is injected because no supported input produces it.

Improvements worth doing

The cleanup loop rewrites rows that did not change and saves more columns than it needs to.

Details

The walk re-saves levels that do not change, and uses full saves. A repeat DELETE on an archived criterion re-saves the criterion and every ancestor, writing one history row per level with nothing changed in it. CompetencyCriterion.save() re-runs full_clean(), so an archive can be refused by an override payload that was valid when it was written. A full save() also overwrites columns another request edited meanwhile. Stop the walk at a level that is already archived, and use save(update_fields=["archived"]) as #874 does.

The delete leans on a retry where locking the rows up front would be more reliable and would avoid a possible deadlock.

Details

Lock the rows the checks read instead of retrying. The retry cannot loop or archive twice, and both archive call sites are inside a transaction. But locking the Tag row, then the ancestor groups, then the criterion keeps the status checks from going stale, which is the only reason the fallback block exists, and it matches #874's groups-before-criteria lock order. The current order (criterion first, then group writes) can deadlock against a concurrent bulk update, which would surface as an uncaught OperationalError and a 500. If the retry stays, except IntegrityError alone is enough: ProtectedError is a subclass of it in Django 5.2.

A result type returned by a public function is not exported.

Details

Export CriterionDeletionResult. It is returned by an exported function but is not in __all__ and has no UNSTABLE marker. The precedent, GradedObjectScore, lives in data.py and is exported.

One scenario is covered only by a mocked test and not by a real one.

Details

Add the depth-2 variant of the "group with its own status" case. The real test puts the status on the leaf group; the ancestor case is covered only by a mocked test. One extra parametrize row with the status on the root is enough; the review probed it and it works.

Two tests simulate a race by scripting call counts, so they would break for the wrong reason.

Details

Simulate the race instead of scripting helper call counts. Two tests patch a private helper with side_effect=[False, True]. If a later change adds one more status check per attempt, those tests raise StopIteration instead of failing on behavior. Use a side-effect function that inserts the real status row on its first call and then delegates to the real helper.

Small things

A commit subject line is longer than the repository guidance asks for (73 characters against "under 72"), though CI's commitlint passed.

Details

Commit 47b564a's subject is 73 characters; the repo's own guidance asks for under 72. CI's commitlint passed.

The same retry explanation is written out three times in the code.

Details

The retry rule is explained three times: in the delete_competency_criterion docstring, in the comment inside the except, and in the trailing comment. Keep the docstring paragraph and one short why-comment.

A helper's name and the main docstring's emphasis do not describe the code as well as they could.

Details

_remove_competency_criterion reads like a second public verb; _attempt_delete_competency_criterion says what it is. The public docstring spends most of its length on the retry rather than on the contract.

A lock comment suggests more protection than the lock gives.

Details

The lock comment in _remove_competency_criterion reads as if the lock settles the race; it only covers status inserts against this one criterion, which is why the retry exists.

Several tests have redundant assertions, overlapping cases, or loosely worded names and docstrings.

Details

Tests: two exists() assertions right after refresh_from_db() cannot fail; test_unknown_criterion_id_404s and test_repeat_delete_after_hard_delete_404s go through the same lookup; hard-delete versus archive could be one parametrized test per file; several docstrings restate the test name; test_repeat_call_on_an_already_archived_criterion_is_idempotent describes how rather than what; test_create_response_reports_archived_false could be one assertion in an existing create test; "leaf-agnostic" in the criterion fixture docstring is jargon.

The URL name differs slightly from the one the ticket gives.

Details

The URL name is criterion-delete; #674 says competency-criterion-delete. The shorter name matches criterion-create; note it on the ticket.

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

Great work!

I mostly found some nits. AI found a bunch of small problems that should be fixed, then this should be ready.

@ufedaseyeuconsultant

Copy link
Copy Markdown
Contributor Author

@jesperhodge Thanks, this was a thorough review, and the main points were right. I rebased onto main (which brought in #874) and reworked the delete path. Summary of what changed, by your action list:

  1. Rebase and feat: add bulk update competency criteria endpoint #874. Rebased onto current main; the conflicts were additive. The shared serializer's archived field now also appears in the bulk-update response, so CRITERION_FIELDS in the bulk-update view test includes it. The PR description says so.
  2. Archived groups coming back. This is handled outside this PR: [BE] Enforce archived in Competency Criteria creation #717 reactivates an archived root when a new course-level group is created under it, and makes the duplicate check ignore archived criteria; [BE] Add Archived Course Keys to GET Competency Criteria & Groups Endpoint #840 adds archived_course_keys to the criteria-groups GET so the frontend can hide courses whose course-level group is archived. A course-level group itself stays archived for the MVP, which [BE] Enforce archived in Competency Criteria creation #717 records as a deliberate decision. The PR description now states this limitation.
  3. Permission inside the function. The function is now delete_or_archive_competency_criterion(criterion_id, user), following feat: add bulk update competency criteria endpoint #874: it checks oel_tagging.can_tag_object itself before taking any lock, and the archive writes set _history_user. The permission object carries the criterion's ObjectTag.object_id (the subsection's usage key), the same object the create view checks; edx-platform's rule accepts both that and a course key.
  4. Locks instead of the retry. One transaction now locks the competency Tag row, then the criterion's ancestor groups in pk order, then the criterion, the same Tag-first order create uses and the same groups-before-criteria order as feat: add bulk update competency criteria endpoint #874. The ancestor chain is read after the Tag lock. With those locks held, a concurrent create waits on the Tag lock, and a learner-status insert on the criterion or any ancestor waits on that row's lock, so the status checks cannot go stale. The ProtectedError/IntegrityError fallback and the retry are gone; any integrity error propagates. I also added order_by("pk") to feat: add bulk update competency criteria endpoint #874's group lock in bulk_update_competency_criteria, so both writers take group locks in the same order.
  5. Untagging. The hard-delete branch now removes the ObjectTag only if it is among the rows get_object_tags() returned, and otherwise raises a ValidationError keyed object_id before writing anything. Since the permission check now runs inside the function, a disabled taxonomy is refused there first, so this guard is defense in depth.
  6. 404 for a concurrent repeat DELETE. The view maps CompetencyCriterion.DoesNotExist to NotFound (and Django ValidationError to a 400, like the create view).
  7. Tests. The two "stops at a surviving sibling" tests now assert the root and siblings stay archived=False. New tests cover: a hard delete next to an archived criterion (the group and its parent end up archived); learner progress on the root (depth 2, parametrized); the 403 view test also asserts the criterion and ObjectTag survive; a 403 for the taxonomy half of can_tag_object; an API-level refusal for a user without permission; the history user on the archived rows; a repeat delete writes no history rows; the disabled-taxonomy untag guard. The tests that scripted side_effect=[False, True] and the injected IntegrityError test are removed, because the paths they covered no longer exist.
  8. Docstrings and PR description. The view docstring now says the URL is tag-scoped because [FE] Manage & Apply Competencies: delete a Competency Criteria Association #710 calls exactly this shape and carries no group id. _cascade_from_group no longer claims that only true leaves hold criteria. The PR description is rewritten (deviations, model name, how to create a status row; the stale cherry-pick paragraph is gone).
  9. ADR questions. I posted those separately below, since they need a human decision rather than code.
  10. Improvements and small things. Archive writes use save(update_fields=["archived"]) and stop at a level that is already archived, so a repeat DELETE writes no history. CriterionDeletionResult moved to data.py and is exported. The helpers are renamed _criterion_has_learner_progress / _group_has_learner_progress. The retry explanation that appeared three times is gone with the retry. The fixture docstring no longer says "leaf-agnostic", and the redundant exists() assertions after refresh_from_db() are removed.

Two things I kept as they are, with the reason:

  • 404 before 403. The view still looks the row up before checking permission, as [BE] Delete Competency Criterion #674's acceptance criteria require (403 without rights, 404 for an unknown id). Criterion ids are sequential and low-value, and the tag's existence is already disclosed by the create endpoint.
  • full_clean() on archive. CompetencyCriterion.save() calls full_clean() itself, so update_fields=["archived"] does not skip it, and an archive can still be refused by an override payload that has since become invalid. Changing that means changing the model's save(), which I left out of this PR.

Lock behavior cannot be exercised by the test suite, because the tests run on SQLite, where select_for_update() does nothing; the lock order is checked by review only. The 73-character subject on one commit stays as is; commitlint passes it.

@mphilbrick211 mphilbrick211 moved this from Needs Triage to In Eng Review in Contributions Oct 27, 2026
@jesperhodge

Copy link
Copy Markdown
Contributor

Hi @ufedaseyeuconsultant , select_for_update() establishes db locks. Those can have far reaching consequences in edx-platform and break things, and Dave warned us to be cautious with these. So we should try to avoid them unless they are absolutely necessary.

According to Claude, these locks are unnecessary and can be removed:

  • L779-781: remove the lineage lookup and the ancestor-group select_for_update().
  • L783: drop select_for_update(), but keep the re-read.
  • L790-796: drop select_for_update() on the ObjectTag and the lock comment at L790-792. Keep the still_referenced check.

And you'd need to clean up the code around that to account for removing these a bit.

The one lock we might be leaving is the tag lock with the select_for_update() on line 778 -

Tag.objects.select_for_update().get(pk=tag_id)
. The lock is already established by tag creation and prevents a real race condition. But please add a comment to @ormsbee about this select_for_update() double checking with him that it is fine.

@jesperhodge

jesperhodge commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

@ufedaseyeuconsultant Claude found one other issue:

Quoting Claude

Regression from a post-review change
_cascade_from_group now returns as soon as it reaches an archived group. It returns before checking whether that group has just become empty. The reachable case:

A criterion is archived because a learner has a status on it.
That learner's user row is deleted. Status rows CASCADE on user deletion (learner_status.py:126-128).
A repeat DELETE now takes the hard-delete branch, deletes the criterion, and the walk stops at the archived leaf group.
I wrote a probe test for this. Afterwards, the leaf and root groups both remain in the database: archived, with no group status, and the leaf with no criteria or child groups at all. That breaks #674's "no empty group is ever left persisted" (ADR-0002 Decision 2). The reviewed commit had no early return and would have deleted both groups.

The early return came from the review's own suggestion to stop at archived levels. The fix is to short-circuit an archived group only on an archive event, where nothing changes anyway. On a delete event, an archived group that is now completely empty and has no group status should still be deleted, and the walk should continue upward. Otherwise it should stop without saving. A test for this case would pin it down. Reachability is low, but it is a correctness gap.

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

Hi Seva, thanks for the good fixes.
I found two more things - see the two comments I left in the PR next to this.
Most importantly we should make sure we don't use select_for_update() where it's not strictly necessary.

@ufedaseyeuconsultant

Copy link
Copy Markdown
Contributor Author

@jesperhodge

Reply to the select_for_update() comment

Removed all three, as listed:

  • The lineage lookup and the ancestor-group select_for_update() are gone, along with the _lineage_group_ids() helper that only they used.
  • The criterion is still re-read inside the transaction, after the Tag lock, without select_for_update(). A criterion deleted by a concurrent call is still reported as DoesNotExist, which the view maps to 404.
  • The ObjectTag is no longer locked. After the criterion is deleted, its ObjectTag is deleted only when no other criterion references it.

I also reverted the .order_by("pk") this PR had added to the group lock in bulk_update_competency_criteria. It only existed to keep a common lock order with the ancestor locks, so with those gone it is no longer needed, and that code now matches main.

The only lock left is the Tag select_for_update(). A create for the same competency takes that lock first, so a concurrent create waits until the delete commits. The docstring now says this.

Without the group and criterion locks, a learner status written at the same moment as a hard delete is not waited for. In that case, the database foreign key from the status row to the criterion makes one of the two writes fail with an integrity error, so the outcome is an error response rather than silent data loss.

Reply to the archived-group cascade comment

Fixed. _cascade_from_group now treats an archived group differently depending on the event:

  • On an archive event, it stops at an archived group, as before.
  • On a delete event, it deletes an archived group that the delete left with no criteria, no child groups and no group-level status, and then continues the walk upward. In every other case it stops there without saving, so no redundant history row is written.

New tests in test_criterion_removal.py:

  • test_delete_after_the_learner_is_gone_deletes_the_emptied_archived_groups covers your scenario. A criterion is archived because a learner has a status on it, then the learner is deleted, then the criterion is deleted again. The criterion, its leaf group and its root group are all gone afterward. This test fails on the previous code.
  • test_delete_leaves_an_archived_group_that_still_holds_an_archived_criterion: the archived leaf keeps another archived criterion, so the leaf and its parent stay archived, with no new history row.
  • test_delete_leaves_an_emptied_archived_group_that_has_its_own_status: an archived leaf emptied by the delete stays, because a learner group-level status still references it.

@ufedaseyeuconsultant
ufedaseyeuconsultant force-pushed the ufedaseyeu/674-delete-competency-criterion branch from ceb5d96 to 3e0580d Compare October 30, 2026 13:45
ufedaseyeuconsultant and others added 12 commits November 2, 2026 17:00
…x#674)

DELETE competencies/<tag_id>/criteria/<criterion_id>/ hard-deletes a
CompetencyCriterion when no StudentCompetencyCriterionStatus references
it, or archives it in place otherwise. On the hard-delete path, the
now-unreferenced ObjectTag is untagged via the same read-merge-write
tag_object() creation already uses, but only once no other
CompetencyCriterion still shares it. The archive path never touches the
ObjectTag.

An ancestor CompetencyCriteriaGroup left with no live children is
cascaded in turn (hard-deleted or archived, per the same learner-status
rule, stopping at the first ancestor that still has live content), so a
removal never leaves an empty group behind per ADR-0002 Decision 2.

The view checks oel_tagging.can_tag_object the same way the create view
does, and delete_competency_criterion() leaves that check to its caller,
like associate_competency_criterion(). The create endpoint's response
now includes the criterion's archived flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
delete_competency_criterion() checked for a StudentCompetencyCriterionStatus
row before opening its transaction. The status foreign keys are
on_delete=PROTECT, so a status row committed between that check and
criterion.delete() made the delete raise ProtectedError (or IntegrityError
from the database constraint), and the endpoint returned a 500 instead of
archiving.

The criterion row is now locked with select_for_update() and the status
check, the hard delete, the ObjectTag cleanup, and the ancestor cascade all
run in one transaction. On MySQL and PostgreSQL, inserting a status row that
references the locked criterion waits for this transaction, so the check
stays valid until the delete. The archive path runs in the same transaction
too.

If the hard delete is still refused, for example because a group-level
status appears for an ancestor that _cascade_from_group() deletes, the
ProtectedError or IntegrityError is caught after the transaction has rolled
back, and the criterion is archived in a new transaction. The result then
reports archived=True.

The tests cover the fallback by making the delete raise ProtectedError (a
real status row created just before the real delete) and IntegrityError.
SQLite cannot reproduce row locks, so the lock itself is not unit-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…penedx#674)

The fallback after a refused hard delete archived the criterion on any
ProtectedError or IntegrityError. The attempt also covers the ancestor
cascade and the ObjectTag cleanup, so an ancestor that gained a status
mid-cascade, or an unrelated integrity failure, archived a criterion
that has no status and reported archived=True.

The fallback now re-checks the criterion's own status and archives only
if one exists. Otherwise the delete is retried once, so the cascade sees
any ancestor status committed meanwhile and archives only that ancestor;
a failure on the retry propagates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion (openedx#674)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…levels (openedx#674)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts (openedx#674)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…der the tag lock (openedx#674)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…penedx#674)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the ancestor-group, criterion and ObjectTag row locks from
delete_or_archive_competency_criterion. The criterion is still re-read
inside the transaction after the competency's Tag lock, the same lock a
create for that competency takes first, so a concurrent create still
waits for the delete. The criterion's ObjectTag is deleted only when no
other criterion references it, as before.

Also revert the pk ordering this PR added to the group lock in
bulk_update_competency_criteria; it only kept a common lock order with
the ancestor locks removed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

_cascade_from_group stopped at any archived group. After a learner whose
status archived a criterion is deleted, a repeat delete hard-deletes the
criterion and left its archived groups behind with nothing in them,
breaking ADR-0002 Decision 2.

On a delete event, an archived group left with no criteria, no child
groups and no group-level status is now deleted and the walk continues
upward. Otherwise the walk still stops at an archived group without
saving it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ufedaseyeuconsultant
ufedaseyeuconsultant force-pushed the ufedaseyeu/674-delete-competency-criterion branch from 3e0580d to b1e27af Compare November 2, 2026 14:03

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

Hi @ufedaseyeuconsultant ,

because the concurrency considerations are so complex here, I gave it another full review, and AI discovered some important additional concurrency issues. I workshopped it with AI for quite a while to get a full picture - I'm pretty sure the points about the possible deadlock are accurate, so we should make sure to fix these.

When you have implemented the fixes, please run a full AI /backend-code-review on your end, and prompt it to focus especially on concurrency and db issues, then fix follow-ups it brings up until you and AI are satisfied. Attach the PR review and a fix report please.

Because this is complex, please run the review on Fable with effort "xhigh".

Here's the AI review:

"

Must fix before merge

Deleting a criterion while someone else edits it can deadlock.

Details

In delete_or_archive_competency_criterion and _cascade_from_group the delete takes the Tag lock, then writes the criterion row (an UPDATE to archive or a DELETE), then writes the leaf group and its ancestors. In bulk_update_competency_criteria the order is reversed: it locks the root, course-level, and leaf groups in one select_for_update() statement, then locks the criteria. Bulk update takes no Tag lock, so the Tag lock does not serialize the two. The review confirmed both orders by reading the code at commit b1e27af; the cycle itself is inferred from row-lock semantics, since SQLite cannot show it.

When an author edits a criterion's rule (bulk update) while the same criterion is being removed (the #710 click), from two tabs or by two authors, the delete holds the criterion and waits for the leaf group, bulk update holds the leaf group and waits for the criterion, and the database kills one request with an OperationalError that nothing catches, a 500. Round 1 named this exact cycle, round 2's ancestor-group locks closed it, and round 3 removed those locks. InnoDB usually picks the transaction that changed fewer rows as the victim, so bulk update, which has written nothing while it waits, loses its edit. InnoDB rolls back the victim's whole transaction, so nothing inside the request can retry. The cycle needs the leaf to be left with no other live criterion, in both the archive and the hard-delete branch. With a surviving live sibling the walk only reads. In the default tree shape every create without a group id makes a new leaf for its criterion, so every delete empties its leaf and the cycle is the common case rather than an edge case.

The fix with the fewest locks is one rule for every authoring writer of a competency's tree: take that competency's Tag row lock first, read only after it, take no other row lock, and write several criteria in ascending primary-key order. It must land with or before this PR.

  • Bulk update moves onto the Tag lock. It reads the group to learn its tag id, takes Tag.objects.select_for_update().get(pk=group.tag_id), drops its two row locks, keeps the archived and membership checks after the lock so they read current data, and saves criteria in ascending primary-key order. ADR-0004 describes the one-lock shape as deadlock-free. The ordering is not optional. Once bulk update no longer locks all its criteria in one statement, each UPDATE takes an exclusive lock on its own, while the grading write inserts status rows in ascending criterion order and takes a shared lock on each criterion. Two different orders would form a new cycle once grading integration lands.
  • Landing commit 7ca4eaa on its own is not enough. It removes the cycle with zero locks, but it leaves bulk update's archived check with no lock behind it: an edit racing an archive writes onto an archived criterion, and an edit racing a hard delete fails with a 500 when the delete commits between the read and the save. Its commit message's premise, that no code path archives groups or criteria, stops being true when this PR lands. Keep its primary-key loop and replace its lock removal with the Tag lock.
  • PATCH takes the same lock. update_competency_criteria_group checks archived and later saves logic_operator with no lock between, so it is check-then-act against this archive. If a delete archives the group in between, PATCH writes onto an archived group and simple_history records a row with archived=False. If the delete hard-deletes the group, the save updates zero rows and Django raises DatabaseError, a 500. There is no cycle, but the fix is two lines and it keeps the rule without an exception the next writer could copy: read the group to learn its tag id, take the Tag lock, re-read the group, then run the existing checks. Both outcomes then become a clean archived error or a 404.

No test exercises the locks with a second connection, although CI runs the suite on MySQL 8.

Details

Plain pytest runs on in-memory SQLite, which has no row locks, so none of the 175 tests can show a lock doing anything. The django52 tox target that CI runs takes DJANGO_SETTINGS_MODULE=mysql_test_settings from tox's base [testenv] section and runs against a MySQL 8 service container, so tests that need real locks can run there and skip locally. Every test below uses two connections: one thread runs the delete and pauses inside its transaction, and a second connection acts while the first is paused. Put them in a new file, tests/openedx_learning/applets/cbe/test_criterion_concurrency.py.

Harness rules. Each one prevents a specific hang or false pass.

  • Mark each test @pytest.mark.django_db(transaction=True). The default test transaction would hide the fixtures from the second connection and would never release the lock mid-test.
  • Skip when connection.features.has_select_for_update is false, so the tests skip on SQLite and run in CI.
  • Run the second actor in a threading.Thread. Django gives each thread its own connection. Catch any exception in the thread and re-raise it in the test, and call connection.close() in the thread's finally, because the table flush after the test waits on any transaction the thread left open.
  • Pause deterministically with two threading.Event objects, never with sleep(). To pause before the walk, patch _cascade_from_group in the api module with a wrapper that signals, waits, then calls the original, and make it pause only on its first call, because the walk calls itself at each level. To pause after the walk, patch CriterionDeletionResult in the api module the same way, since the function constructs it last, inside the transaction.
  • Set innodb_lock_wait_timeout to a few seconds on each thread's connection with SET SESSION, so a test that blocks where it should not fails in seconds instead of fifty. Release every paused thread in a finally.
  • To check that a row is locked, run Tag.objects.select_for_update(nowait=True).get(pk=...) inside transaction.atomic() on the probing connection. It raises DatabaseError at once while another connection holds the row, with no timing involved.
  • To check that a thread is blocked, join() it with a short timeout and assert it is still alive.

The tests to add. Tests 4 and 5 fail at head and pass once the deadlock fix above is in, so write them first.

Test Steps Must assert
1. The Tag lock is held for the whole request Pause the delete before the walk. Probe the Tag row with nowait=True from the test. Resume, join, probe again. The first probe raises DatabaseError. The second succeeds.
2. A create for the same competency waits, and its criterion survives Pause the delete of a leaf's only criterion before the walk. In a thread, start a create for the same competency on a second unit in the same course. Resume the delete, join both. The create thread is still alive while the delete is paused. Afterward the new criterion exists under a new leaf, course-level group and root, and the deleted criterion and its original leaf, course-level group and root rows are gone.
3. Two deletes of sibling criteria leave no empty leaf Put two criteria in one leaf. Pause the delete of the first before the walk. In a thread, start the delete of the second. Resume, join both. The second thread is alive while the first is paused. Afterward both criteria and the leaf, course-level group and root are gone.
4. Delete and bulk update do not deadlock Leaf with one criterion. Pause its delete before the walk. In a thread, start a bulk update of that criterion. Resume, join both. Parametrize over hard delete and archive, adding a learner status row for the archive case. Neither thread raises OperationalError. The delete returns normally. Bulk update raises CompetencyCriterion.DoesNotExist or CompetencyCriteriaGroup.DoesNotExist. At head one thread raises OperationalError, MySQL error 1213, instead.
5. PATCH does not write onto an archived or deleted group Leaf with one criterion. Pause its delete after the walk. In a thread, start a PATCH of the leaf's logic operator. Resume, join both. Parametrize as in test 4. PATCH raises CompetencyCriteriaGroup.DoesNotExist after a hard delete, or CompetencyCriteriaGroupArchivedError after an archive, and no group history row records a logic-operator change. At head PATCH raises DatabaseError after a hard delete and writes onto the archived group after an archive.
6. A grade write and a hard delete fail one side, never silently Ordering A: in a thread, inside transaction.atomic(), call record_graded_object_statuses for the criterion's object and pause before the transaction.atomic() block ends. Run the delete in a second thread, confirm it is blocked, then resume the grade thread. Ordering B: pause the delete before the walk. In a thread, inside transaction.atomic(), call record_graded_object_statuses for the same object, confirm it is blocked, then resume the delete. A: the delete thread raises IntegrityError, the criterion and the status row both still exist, and a second delete call archives the criterion. B: the grade thread raises IntegrityError, the criterion is gone, and no status row exists.

Run them locally against the container that the settings module documents:

docker run --rm -e MYSQL_DATABASE=test_oel_db -e MYSQL_USER=test_oel_user \
  -e MYSQL_PASSWORD=test_oel_pass -e MYSQL_RANDOM_ROOT_PASSWORD=true -p 3306:3306 mysql:8
DJANGO_SETTINGS_MODULE=mysql_test_settings pytest --no-cov tests/openedx_learning/applets/cbe/test_criterion_concurrency.py

tox -e django52 -- <path> runs the same thing. The per-writer lock-point tests in the action list, which patch the lock with a side effect, stay separate: they run on SQLite too, and they cover a different property, that each writer reads after the lock.

The pull request description was not rewritten, although the author's reply of 2026-10-26 said it was, and it is still inaccurate.

Details

The author's reply in the review thread says the description was rewritten. It still says #613 has not landed on main and that the branch cherry-picks it with a renumbering commit (the branch has twelve commits of #674 work and no migrations). It still says untagging goes through the tag_object() read-merge-write call (the code deletes the ObjectTag row directly through the ORM). It still names the status model StudentCompetencyCriteriaStatus (the class is StudentCompetencyCriterionStatus), gives no way to create a status row for manual testing, and says the 403 is checked "for the criterion's course" when the check is against the criterion's ObjectTag.object_id. It has no deviations section: the URL is scoped by tag rather than group, the URL name is criterion-delete, archived now appears in the bulk-update response, and archived groups stay reachable by the create path until #717 lands. Rewrite it.

Two comments the review asked for were never posted.

Details

Round 3 asked for a comment pinging ormsbee to double-check the remaining Tag select_for_update(); the only mention of ormsbee in the thread is that request. The author's reply says the ADR questions were "posted separately below"; no such comment exists. The author reported both as done. Post both. The content for the ormsbee check-in is in the finding on what the Tag lock guards, under "Improvements worth doing", and the content for the ADR comment is the finding on the four ADR deviations in the same section.

Two code comments on the create path say the Tag lock exists only for the duplicate check, and the new delete makes that false.

Details

The _create_leaf_group docstring says "The caller's tag lock is for _reject_duplicate_criterion()", and the comment at the lock in associate_competency_criterion says "the tag lock only serializes _reject_duplicate_criterion()". The delete now relies on that same lock to keep a concurrent create's get_or_create and leaf INSERT from landing between the walk's emptiness check and the collector SELECT inside group.delete(). The diff does not touch either comment. Add one sentence to each saying the delete depends on the lock too.

One test describes a mechanism the code no longer uses.

Details

test_hard_delete_leaves_a_tag_of_another_competency_on_the_same_object in test_criterion_removal.py monkeypatches get_object_tags in the api module, which the delete path no longer calls; the review removed the patch and the test still passed. Its docstring says the tag list is stubbed and "the delete must not depend on reading it", which is no longer true of anything. The remaining assertion (another competency's ObjectTag on the same subsection survives) does protect something: it fails if the delete filters by object_id instead of by primary key. Drop the monkeypatch, the stale_list, and the monkeypatch parameter, and reword the docstring to what it asserts.

Improvements worth doing

The delete's documentation does not explain everything its lock protects.

Details

The delete's docstring says only that a concurrent create waits. The lock prevents three things, all following from Django's Python-side cascade:

  • A create for the same competency that commits a new leaf and criterion between the walk's emptiness check and the collector SELECT inside group.delete() would have those rows collected and deleted silently, after the creator already received a 201.
  • Two deletes of sibling criteria in one leaf would each still see the other's criterion and leave an empty leaf behind.
  • A create that reuses the same ObjectTag and commits between the "still referenced" check and the ObjectTag delete would have its criterion cascaded away.

No lock-free alternative keeps all three. Catching IntegrityError cannot help because the silent-loss case raises nothing. A raw conditional DELETE would skip the collector but also skip the simple_history deletion row that ADR-0003 requires. Changing the foreign keys to RESTRICT is a model change outside #674, and it would fix only the first race: two sibling deletes never try to delete the leaf, and the criterion's ObjectTag foreign key stays CASCADE. It would also break course-run deletion, which reaches leaves only through parent. Locking the root group row instead of the Tag does not work either. The root may not exist yet when a create runs, and the walk can delete it mid-request, so every writer would need a get-or-create-then-lock loop. One nuance on the first race: the loss is silent only when the create commits inside the window between the emptiness check and the collector's SELECT. A create whose INSERT ran before that window and committed after it makes the delete's DELETE wait on the create's shared lock and then fail with an integrity error, a 500 rather than a loss.

The same docstring, next to the lock, should state that the code assumes READ COMMITTED. The view's get_object_or_404 and the function's first read run before the Tag lock, inside the request transaction. Under READ COMMITTED the re-read after the lock sees a concurrent delete that won, and the walk's exists() checks see concurrent commits. A deployment can set isolation_level to REPEATABLE READ in the database options. Then the re-read sees the pre-lock snapshot, a racing duplicate DELETE returns 200 and writes a second deletion history row, and two sibling deletes can leave an empty group. ADR-0004 rejected a design because its "correctness would depend on the isolation level, silently", and this is the same dependency.

The lock is held until the request commits. Create and delete take it today, and bulk update and PATCH join them with the fix above. Inserts that reference the Tag (object tags from ordinary Studio tagging in any course, groups, the roll-up's competency status, child tags from a taxonomy import) and updates or deletes of the Tag row itself wait meanwhile, for the length of one authoring request. None of those waiters holds anything the delete needs, so none can form a cycle, except an admin or import deleting the Tag, a roll-up wrapped in a transaction, and any single transaction that untags this competency from one unit and tags another unit with it. Write the three races, the isolation assumption, and the waiters in the delete docstring in a few sentences, note at the create lock that delete depends on it, and use the same text as the ormsbee check-in.

Saving a learner's grade at the same moment as a hard delete fails one side with an error, and the docstring should name both outcomes.

Details

The delete checks for a status row, then deletes the criterion. A grade write that inserts a status row at the same moment is not waited for, and which side fails depends on the order:

  • Status INSERT first: the author's DELETE waits for the grade transaction and then fails the foreign-key check, a 500. A second click archives the criterion.
  • DELETE first: the grade's INSERT waits for the delete to commit and then fails inside the learner's grade transaction.

On PostgreSQL the check runs at commit instead, so the grade side usually fails there, but this deployment runs MySQL. The author's position that this is "an error rather than silent data loss" is true, but the grade side is the worse side and the docstring names neither outcome. Catching IntegrityError around the delete in a savepoint and archiving instead is not a sound fix: it never helps the grade side, and it brings back the retry branch round 1 found untestable.

Decision: name both outcomes in the delete docstring, and require in the grading-integration ticket that record_graded_object_statuses and the roll-up run inside tasks that retry on DatabaseError, as edx-platform's grade tasks already do. On retry the deleted criterion is simply gone, and the roll-up's group-status insert, which fails with an integrity error when the delete has removed the group, is skipped the same way. Do not add a 409 mapping now: no edx-platform caller of the grading write exists yet, the author-side failure on MySQL is a clean full rollback that a second click resolves, and the catch would have to be either broad, hiding real integrity bugs, or specific to MySQL's foreign-key error code. Revisit it when grading integration lands.

Four places where this change departs from the written design decisions need the ADRs amended.

Details

#674 already made the first three of these calls, and the fourth follows from the lock rule in the first Must-fix item, so nothing is left to decide. What is missing is the record: one line each in the PR description's deviations section, and an amendment with a changelog entry in the ADR concerned.

  • _group_has_learner_progress checks the group-status table directly, while ADR-0002 Decision 7 says that table is "not independently checked for this purpose". #674's AC12 requires the check, so amend Decision 7.
  • An archived criterion's ObjectTag stays live, so the competency tag stays visible on the subsection, while ADR-0003 Decision 3 says ordinary reads exclude archived associations. #674 accepts it for the MVP, so amend Decision 3 to allow the exception.
  • ADR-0002 Decision 2 says "backend validation must reject" empty groups, and #674 chose to cascade them away instead. Amend Decision 2 to name the cascade as the enforcement mechanism.
  • ADR-0004 Decision 6 says "no other path takes a lock". Read literally it contradicts the Tag lock that create and delete take, and that bulk update and PATCH will take after the fix. Narrow it to learner-status writers and record that authoring writers serialize per competency on the Tag row.

A parameter of an internal function has no effect on what the code does.

Details

The event parameter of _cascade_from_group changes nothing. Forcing event = "delete" on every call, or swapping either archive branch's next_event to "delete", left all 175 tests green; forcing "archive" failed six. After an archive the archived child still exists, so the existence checks already decide every place event is tested. Drop the parameter and let the existence checks decide, which also simplifies #675's reuse, or add a test that distinguishes the two if a reachable state exists (the review found none).

Deletion history records can lack the user who acted.

Details

The archive path sets _history_user on the instance. The delete path calls .delete() on the criterion and on each group without setting it, so simple_history falls back to the request middleware, and a shell, task, or plugin caller writes deletion rows with no user. That is what this module's own comments say must not happen. Set _history_user = user before each .delete().

One step of the delete has no test that fails when the step is removed.

Details

Deleting the re-read line under the Tag lock leaves all 175 tests green. A test that patches Tag.objects.select_for_update to delete the criterion row and then return the real queryset fails without the re-read and passes with it. Add it to test_criterion_removal.py.

Archiving re-validates the rule payload, and the view's 400 test should use that real case.

Details

CompetencyCriterion.save() always calls full_clean(), so the archive write re-validates the override payload although the archive changes only the flag. The review confirmed it with real input: a criterion whose stored payload has become invalid and that has a learner status returns 400 on DELETE with the error under __all__, and can be neither archived nor deleted. This is reachable only if payload validation is tightened after rows exist. Decision: accept it for now, since it needs that later tightening to become reachable, and cover it with a real test. test_delete_maps_a_validation_error_from_the_api_to_400 raises {"object_id": "x"}, a key the endpoint cannot produce, so replace the mocked test with one that archives a criterion carrying an invalid stored payload. If the case ever bites, skipping payload validation when update_fields excludes the rule fields is a model change for a follow-up ticket.

Two tests largely overlap.

Details

test_untag_with_the_competency_taxonomy_disabled_removes_only_the_orphaned_object_tag still passes with the taxonomy-disable and the permission swap removed, and the disabled-taxonomy 403 is already covered in test_views.py. Merge it with the other "other ObjectTag survives" test: keep one test, named for what it asserts, without the word "untag".

A code comment states a fact as always true when it holds only through one code path.

Details

The comment "A group's tag is always a competency tag, so its taxonomy is always set" is true only through the create path: Tag.taxonomy is nullable, the group's tag foreign key has no taxonomy constraint, and a comment in update_competency_criteria_group in the same file says the opposite. Say "the create path only accepts competency tags", or call resolve_competency_tag as that function does. The assert itself has six precedents in the module.

Small things

The re-read inside the transaction skips a select_related, costing two extra queries.

Details

The re-read inside the transaction has no select_related("group", "object_tag"), so the two attribute reads after it cost two more queries, and the same row is read three times (view, first read, re-read).

The main docstring of the delete function contains several inaccuracies and formatting errors.

Details
  • It says "the whole operation runs in one transaction", but the first read and the permission check precede atomic().
  • It says the permission is "as bulk_update_competency_criteria requires", but bulk update checks a course key and this checks a usage key.
  • It states PermissionDenied twice.
  • It omits the "only archived children remain" archive branch.
  • It uses single backticks, which RST renders as interpreted text (also in _cascade_from_group).

Several comments and docstrings are duplicated or needlessly reworded.

Details

The "set on the instance so attribution doesn't depend on simple_history's request middleware" comment now appears three times in the file. The cast(Any, ...) comment restates main's one-line version. The view docstring is reflowed mid-sentence.

A competency tag deleted at the same moment as a delete request produces an unhandled error.

Details

Tag.DoesNotExist from the lock read (an admin deleting the competency tag at the same moment) is unmapped, a 500. Create has the same exposure.

Some view tests duplicate each other and checks the API tests already make.

Details

In test_views.py, test_unknown_criterion_id_404s and test_repeat_delete_after_hard_delete_404s take the same lookup miss and could be one parametrized test. The view tests also repeat row-state assertions the API tests already own.

The ticket does not yet note where this change departs from it.

Details

The deviations from #674 (tag-scoped URL, URL name) are still not noted on the ticket.


The accuracy gate returned PASS WITH FIXES; the fixes are applied above. The cleanup added 12 definitions or glosses that nobody has confirmed against the code or the ADRs.

Definitions the cleanup added, unconfirmed
  • "Review rounds": rounds 1 to 3 are the earlier review passes on this pull request.
  • The group's tag foreign key points at the competency's Tag row in openedx_tagging, and the "Tag lock" is a select_for_update() on that row.
  • The labels "the walk" (for _cascade_from_group), "create", "bulk update", and "PATCH" (for the three named API functions), and "deletion collector" (for Django's Python-side cascade emulation).
  • Isolation level, glossed as the rule for which concurrent changes a statement sees; REPEATABLE READ, glossed as showing a transaction the snapshot from its first read.
  • Deadlock, glossed as a cycle in which two transactions each wait for a lock the other holds, which the database breaks by aborting one with an error.
  • Check-then-act, savepoint, and deferred foreign key glosses.
  • ADR-0002, ADR-0003, and ADR-0004 named as "competency criteria model", "competency criteria versioning", and "competency mastery concurrency", located in docs/openedx_learning/decisions/ (the file names exist on the branch; the ADR bodies were not reread for this).
  • "AC" expanded as acceptance criteria, and the statement that #674 lists numbered acceptance criteria (the numbering was added by the review's ticket extract).
"

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

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Unicon Code Review
Status: In Eng Review

Development

Successfully merging this pull request may close these issues.

[BE] Delete Competency Criterion

3 participants