Repository navigation
Conversation
|
@tbain I got stuck on the third point of "Choices that differ from the ticket", which I didn't understand ("The taxonomy check matches..." . I had Fable explain it to me and rewrite it: " |
jesperhodge
left a comment
There was a problem hiding this comment.
Hey great work! Only some minor details that AI pointed out to me.
AI Code Review
1. The setter takes a database row lock even when the row is already deletion-locked. openedx#782 will call it in every grade transaction, and every learner graded on the same criterion shares one association row, so their grades would queue behind each other on that row lock. The approved design (openedx#655) says to read first and update only rows not yet deletion-locked, and ADR 0004 allows no such lock.
Fix steps and sources
lock_object_tags_for_deletion runs filter(pk__in=ids).update(deletion_locked=True) even when the row is already locked.
Fix, inside the setter:
- Read
(pk, deletion_locked)for the given ids with a plain, non-locking query. - Raise
DoesNotExistfor any missing id before writing. - Only if some ids are still unlocked, run
filter(pk__in=still_unlocked, deletion_locked=False).update(deletion_locked=True), keepingatomic()around that write. - Edit openedx#782's sentence "Do not add a read to avoid a write here", which contradicts the design.
If you and the ticket authors instead accept the contention, record it in the PR and the ticket, because it reverses the approved design.
Sources:
- openedx#782 says the lock is set "in the same transaction as the learner status write", which by ADR 0004 Decision 1 is the grade transaction.
- mgwozdz-unicon's openedx#655 proposal of 2026-08-27, which ormsbee approved, reads: "The lock write reads
deletion_lockedfirst and only writes when it's stillFalse.ObjectTagis one row shared by every learner graded against that criterion, so an unconditional write on every grade would take a write lock on that row inside every single grade transaction, serializing every other learner's grade behind whichever one currently holds it, even though only the very first write ever actually changes anything." The same author restated it on 2026-08-28. - ADR 0004 Decision 6 says no path other than a staff correction takes a lock.
- Which text wins is a decision for the ticket authors. The ADR is accepted and the openedx#655 text is the approved design, so the review treats openedx#782's sentence as the one that drifted.
Inferred: InnoDB, MySQL's default storage engine, should lock every row an UPDATE matches until the outer transaction commits, even when the value does not change. The review did not run it on MySQL, and the SQLite test suite cannot show it, which ADR 0004 itself notes.
2. Fix the PR description: it says the setter checks ids before writing, but the code writes, then rolls back.
Details
The outcome is the same. Item 1's fix makes the description true; otherwise, correct it. Captured SQL on failure: SAVEPOINT, UPDATE, SELECT, ROLLBACK TO SAVEPOINT, RELEASE SAVEPOINT. The outcome is still all-or-nothing and the docstring is correct, but a reviewer checking criterion 4 ("writes nothing") from the description would review the wrong mechanism. If you skip item 1's fix, reword the description to "writes inside a transaction and rolls back if any id did not match".
Decisions to make or write down
3. Agree on the setter's name in the PR thread, then update openedx#777 and openedx#782 to match.
Details
The PR ships the plural lock_object_tags_for_deletion; openedx#777 and openedx#782 use the singular lock_object_tag_for_deletion. The plural matches the module's get_object_tags and delete_object_tags, and ormsbee's openedx#777 approval left room to workshop names, but until the rename is confirmed, openedx#782 names a function that does not exist.
4. Decide what is_taxonomy_locked_for_deletion(None) returns. It likely says "locked" because of locked rows left over from deleted taxonomies.
Details
Return False early, or document that None is unsupported. Deleting a taxonomy sets its associations' taxonomy_id to NULL, and filter(taxonomy_id=None) becomes IS NULL, so None matches those rows (inferred, not reproduced). The signature says int, so this is a decision rather than a bug.
Improvements worth doing
5. Reject string, bytes, and boolean ids. "12" locks rows 1 and 2, True locks row 1, and locks cannot be undone through the API.
Details
True passes the isinstance(..., int) check and locks primary key 1, and "12" is iterated as characters and locks primary keys 1 and 2. A mixed list like [1, "1"] raises a false DoesNotExist naming '1', because the set holds two members and the UPDATE matches one row. A list of two missing ids of mixed types crashes in sorted() with a TypeError. The review reproduced every case in this item in a scratch script.
Follow tag_object, which raises ValueError for a non-list, or normalize with {int(i) for i in ...}, and add one parameterized test.
6. Return early on empty input, so it issues no lock query, as openedx#782 requires.
Details
openedx#782's no-criteria path must "issue no lock query at all"; atomic() emits SAVEPOINT and RELEASE even for empty input. Add the guard and a test for it.
7. Complete the docstrings: say that locks are permanent and that the readers don't lock rows.
Details
- The setter's docstring should say that the lock is permanent with no unlock API, that it nests as a savepoint and is not durable, and that it holds row locks until the caller commits.
- The readers' docstrings should say they are non-locking point-in-time reads, so openedx#779 and openedx#780 must re-check
deletion_lockedin their DELETE's WHERE clause. - Move the "Archived associations count..." sentence above the
:paramlines, out from after:return:.
8. Test the readers' edge cases: unknown tag ids and empty input.
Details
get_tags_locked_for_deletion's docstring promises to omit unknown tag ids, and nothing tests it. Test empty input on both readers.
9. In the multi-id test, assert one UPDATE instead of three queries, so item 1's fix doesn't break it.
Details
test_lock_multiple_ids_in_one_call uses assertNumQueries(3), which counts atomic()'s SAVEPOINT and RELEASE, so any transaction change breaks it, including item 1's fix, even with correct behavior. Use CaptureQueriesContext and assert exactly one UPDATE.
Tests ran on SQLite only, so CI gives the two MySQL-sensitive tests, this one and the idempotency test, their first MySQL run.
Small things
- Consider capping the list of missing ids in the
DoesNotExistmessage. - Export
ObjectTagDoesNotExistbesideTagDoesNotExist. - Comment the MySQL
FOUND_ROWSdependency at the count check.
Details
- The
DoesNotExistmessage lists every missing id, with no cap. - Export
ObjectTagDoesNotExist = ObjectTag.DoesNotExist, so callers need not import models. - On MySQL, idempotent re-locking relies on Django's
FOUND_ROWSflag. The comment is unnecessary if item 1's fix lands, because that fix removes the dependency.
| """ | ||
| An archived but locked association still counts for both readers. | ||
|
|
||
| This guards against adding ``.unarchived()`` to the readers, which would hide associations that anchor | ||
| learner history. | ||
| """ |
There was a problem hiding this comment.
| """ | |
| An archived but locked association still counts for both readers. | |
| This guards against adding ``.unarchived()`` to the readers, which would hide associations that anchor | |
| learner history. | |
| """ | |
| """ | |
| get_tags_locked_for_deletion() and is_taxonomy_locked_for_deletion() still report | |
| a locked association after it has been archived. | |
| Removing a locked tag from content archives the association instead of deleting | |
| it. If these functions skipped archived rows, deleting that tag or its taxonomy | |
| afterward would really delete it, and the learner mastery status pointing at the | |
| association would lose its tag. Adding .unarchived() to either function makes | |
| this test fail. | |
| """ |
There was a problem hiding this comment.
For this one, I asked Claude to make the comments in general less verbose, so while it applied this comment it then edited it for verbosity. I think the new comment still serves its purpose to get this message across, albeit it might be a little terse. Let me know if you would prefer to keep the longer comment, though.
| A deletion lock records that an association must be preserved. This function only sets ``ObjectTag.deletion_locked`` | ||
| to ``True``; no other field is changed. |
There was a problem hiding this comment.
AI likes to specify what a function does not do. I suggest we remove this from the docstring.
1e5403d to
c2dc21c
Compare
143afa4 to
3f92aee
Compare
|
@jesperhodge I think all of your comments are addressed;
|
3f92aee to
7c3441b
Compare
jesperhodge
left a comment
There was a problem hiding this comment.
Hi Tyler,
this looks really good, only one thing.
In lock_object_tags_for_deletion, you fixed the issue I found with the db lock correctly and removed the db lock. That's good.
However you also removed atomic(), which creates another small issue.
AI and me both think that it would be best to keep atomic() without the db lock - so NO db lock, but atomic() yes. Because that will fix this race condition you identified earlier.
Something like this, according to Claude:
if unlocked:
with transaction.atomic():
updated = ObjectTag.objects.filter(pk__in=unlocked, deletion_locked=False).update(deletion_locked=True)
if updated != len(unlocked):
surviving = set(ObjectTag.objects.filter(pk__in=unlocked).values_list("pk", flat=True))
deleted = set(unlocked) - surviving
if deleted:
raise ObjectTag.DoesNotExist(...)
c2dc21c to
2bd9204
Compare
Description
Adds three public functions to
openedx_tagging.api. Together they let another app mark a tag association as depended upon, and let the tagging app's delete paths ask whether a tag or a taxonomy has a locked association beneath it.lock_object_tags_for_deletion(object_tag_ids)setsObjectTag.deletion_locked=Trueon one association or several. It reads the current flags first and raises ObjectTagDoesNotExist before writing if any id does not exist, then runs one UPDATE for the ids still unlocked. An already-locked association is read, not written, so the common case of a second learner graded on the same criterion takes no row lock. If a row is deleted between the read and the write, the setter detects the shortfall and raises for that id.get_tags_locked_for_deletion(tag_ids)returns the subset of the given tag ids that have at least one locked association directly on them. It runs one query no matter how many ids it receives.is_taxonomy_locked_for_deletion(taxonomy_id)returns whether any association in the taxonomy is locked, as a single existence check.Both readers count archived associations. An association that was locked and later archived still anchors a learner's mastery history, so the tag and taxonomy above it must still read as in use.
openedx#782 is the first caller of the setter. openedx#779 and openedx#780 call the readers to decide between archiving and hard delete. As the ticket specifies, this PR adds no REST endpoint and no unlock function. The functions do no permission checks; the caller authorizes, as the
api.pymodule docstring states.Impacted roles: Developer only. Nothing calls these functions yet.
Choices that differ from the ticket
lock_object_tags_for_deletion, plural. The ticket calls itlock_object_tag_for_deletion. The function accepts one id or many, and @ormsbee's approval on [BE] Add deletion-lock functions to openedx_tagging openedx/openedx-core#777 left room to adjust the names if they read awkwardly. The two readers keep the ticket's names.ObjectTag.objectsreturns every row, and hiding archived rows is an opt-in.unarchived()call. The readers therefore use plainObjectTag.objects.test_archived_locked_association_still_countsfails if.unarchived()is added to either reader, which is the guard the ticket asks for.Supporting information
Testing instructions
pytest --no-cov tests/openedx_tagging/test_api.py::TestDeletionLock(17 tests)pytest --no-cov tests/openedx_tagging/python manage.py shell). These steps create rows in your dev database.lint-importskeeps both contracts.Other information
deletion_lockedfield comes from [BE] Add an archived field to Tag, Taxonomy, and ObjectTag, and a deletion_locked field to ObjectTag openedx/openedx-core#776.🤖 Generated with Claude Code