Move content annotation onto the Django ORM - #15170
Conversation
|
Acceptance criteria from #15100, ticked here because this account cannot edit an issue body. General
Testing
Testing notes from the issue
|
Build Artifacts
Smoke test screenshot |
f8bf3cc to
f7fc50a
Compare
df62135 to
3c1680f
Compare
|
Can you include the benchmark results in the PR body please? Just saying that you did the benchmark doesn't show anything. |
|
Oh, you added in a comment, please put in the PR body clearly too. |
rtibbles
left a comment
There was a problem hiding this comment.
This is looking good - a little cleanup for readability would be helpful. The additional tests are helpful to guard against regressions, is there anything else missing?
Also, I think 11 commits might be a bit much for these changes? A shorter commit history would seem quite helpful.
|
|
||
| class CorrelatedExistsUpdateTestCase(TransactionTestCase): | ||
| """ | ||
| The annotation conversion rests on Django resolving OuterRef in an update() |
There was a problem hiding this comment.
It's not clear to me what this means - ideally the test class name would be self explanatory, but if not, this needs to be clearer.
There was a problem hiding this comment.
Renamed to ExistsInUpdateEvaluatesPerRowTestCase, and the docstring now names the caller it guards:
The leaf availability passes set available=Exists(...) in an update(), which is only correct if Django evaluates that Exists once per row, matching OuterRef("id") to the row being updated. Evaluated once for the whole statement instead, every node would come out with the same availability.
| _ANCESTORS_SQL = """ | ||
| UPDATE {table} SET ancestors = ( | ||
| SELECT | ||
| -- Get all of the JSON from the parent's ancestors field, but remove the |
There was a problem hiding this comment.
I appreciate the care taken to retain the comments in their original position. It does make me wonder if most of this could be done in Django, rather than needing raw SQL. If it's not possible, that's fine - but I think we might be better off pulling the comments out of the SQL string, and just making a shorter summary comment for the _ANCESTORS_SQL.
There was a problem hiding this comment.
Comments pulled out of the SQL, replaced with a four-line summary above the constant.
Kept as SQL: this one concatenates a JSON string out of the parent's, so an ORM version is Concat over Substr/Length/Case/Replace with a Cast on every operand to keep Concat from rejecting the mixed JSONField/text types — longer than the SQL and harder to check against the byte-identity test. The issue scoped it that way too ("Keep set_channel_ancestors as raw parameterized SQL").
3c1680f to
2c26f6d
Compare
|
Squashed to 4 commits: the checksum lookup, Two coverage gaps I know of and left:
|
LocalFile checksum filters need the same inline-literal treatment as UUIDs to stay under SQLITE_MAX_VARIABLE_NUMBER. Registered on ForeignKey as well as CharField so it resolves through File.local_file_id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Local file availability writes route through the content_db alias, so the default database and a destination file follow one path. With no destination they join an enclosing transaction.atomic() instead of committing independently. - get_channel_node_depth returns 0 for an empty channel. fetchone() on a max() select always returns a row, so the old None guard never fired and recurse_annotation_up_tree died on range(None, 0, -1). - _MPTT_descendant_ids_queryset returns None when no constraint applies in a range, so an inclusion constraint that matches nothing updates nothing while an exclusion one excludes nothing. - available_children_rollup and coach_content_aggregate are shared with the importability and upgrade rollups. The rollup subqueries gain a GROUP BY parent_id the SQLAlchemy version had no need of; each is correlated to a single parent, so there is exactly one group. - set_channel_ancestors stays SQL, raw and parameterized through a Django cursor: it builds a JSON string with substr, length, case and concatenation rather than expressing a query. - The test modules lose their bridge scaffolding, which patched a get_engine nothing they exercise calls any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The temporary annotation in get_channel_annotation_stats rolls back through transaction.set_rollback rather than a bridge transaction, and reads land as ORM values_list, so coerce_key is no longer needed to turn PostgreSQL uuids back into hex. The two historical rollup hooks differ only in the column they roll up, so they share one helper. sqlalchemy.exc.DatabaseError stays: import_external_content_dbs catches it alongside sqlite3.DatabaseError around import_channel_from_local_db, which still goes through channel_import.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
annotation.py and importability_annotation.py were its only callers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2c26f6d to
849c999
Compare
rtibbles
left a comment
There was a problem hiding this comment.
Changes make sense, and test coverage is only expanded not modified - giving confidence in the refactor. Also seems to produce a very small performance improvement, so at worst it is a wash.
Summary
Content annotation is the slowest content operation on low-end hardware, and it ran through SQLAlchemy Core against tables reflected from the database. It now issues the same
UPDATE … WHEREstatements through the Django ORM.set_channel_ancestorsstays raw parameterized SQL, because it builds a JSON string rather than expressing a query. Annotating a channel with no nodes no longer raises.The checksum filter's inline-literal workaround for
SQLITE_MAX_VARIABLE_NUMBERmoves out ofsqlalchemybridge.pyand into achecksuminlookup inkolibri/core/mixins.py, alongside the UUID one it mirrors.References
Fixes #15100. Depends on #15099. Blocks #15101. Benchmark harness: #15110. Acceptance criteria with evidence: first comment below.
Reviewer guidance
kolibri/core/content/utils/annotation.py:455—available_children_rollupstrips MPTT's ordering solftcannot join theGROUP BY. Check every rollup subquery in the diff goes through it.kolibri/core/content/utils/annotation.py:367— with no destination, these writes now run on Django's connection and join an enclosingtransaction.atomic()instead of committing independently. Check no caller relies on the old independence.kolibri/core/content/utils/annotation.py:216—_calculate_batch_paramscame out byte-identical toimport_export_content.py:24. Unifying them means moving it somewhere both can import, sinceimport_export_contentalready imports through toannotation.Deviations from the issue spec
content/upgrade.py:19still imports SQLAlchemy, against the "no SQLAlchemy" criterion:from sqlalchemy.exc import DatabaseErrorbacks a live handler aroundimport_channel_from_local_db, which runs through the out-of-scopechannel_import.py. Is that the boundary the issue meant?AI usage
Used Claude Code to convert the four modules and write their tests, following a plan I directed. Verified with the content suite on SQLite and PostgreSQL 16, the content operations benchmark against both channels, and prek.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
🟡 Waiting for feedback
Last updated: 2026-08-08 23:59 UTC