Skip to content

Add a benchmark for channel import and content annotation - #15110

Merged
rtibbles merged 4 commits into
learningequality:developfrom
rtibblesbot:issue-15097-d36e60
Aug 4, 2026
Merged

Add a benchmark for channel import and content annotation#15110
rtibbles merged 4 commits into
learningequality:developfrom
rtibblesbot:issue-15097-d36e60

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an operator-run benchmark script timing channel import and content annotation against two published channels. Compares a capture against a baseline and exits non-zero when a phase regresses past a threshold.

Most of the file is what a trustworthy verdict costs: an untimed content-table reset that VACUUMs (and ANALYZEs on postgres) so iteration N is not measured against a database the previous iteration degraded, an exclusive lock on the benchmark home, and a refusal to report a capture whose import quietly did nothing.

References

Reviewer guidance

  • content_operations_benchmark.py:252-261 — the script assigns KOLIBRI_HOME rather than inheriting it, because reset_content_tables() deletes every channel, node and local file in the home it points at. Check no path reaches that delete against a home the operator did not name.
  • content_operations_benchmark.py:110-123 — kolibri, Django and sqlalchemy imports are deferred into their callers so KOLIBRI_HOME is set before kolibri.utils.conf snapshots it. Is that constraint discoverable enough to survive a later module-scope import?
  • Both channels resolve to NoLearningActivitiesChannelImport — the class comes from min_schema_version (1 on both), not the inferred version — so neither ATTACHes ContentNode. Should the fixture set include a channel that does take the ATTACH path?

Measurements

sqlite, --runs 3 (the default), both channels. x86_64 Linux (kernel 7.0.9), Python 3.14.6, KOLIBRI_HOME under ~/.cache on btrfs (not a tmpfs). Means over three samples:

Channel schema inferred / min import class fresh_import no_attach annotation upgrade_import drive_enum
1ceff536… Khan Academy (English) 3 / 1 NoLearningActivitiesChannelImport 15.77 s 15.91 s 2.61 s 22.34 s 0.011 s
c9d7f950… Khan Academy (English - US curriculum) 5 / 1 NoLearningActivitiesChannelImport 12.41 s 13.87 s 2.79 s 18.73 s 0.008 s
  • fresh_import_no_attach separates on SQLAlchemy statements (486 → 499, 405 → 416) but not on wall clock, per the third bullet above.
  • drive_enumeration falls below the 0.05 s --min-phase-s floor: reported, not enforced.
  • Each iteration also pays two untimed content-table resets, 46-63 s each now that the reset ends in a VACUUM.

PostgreSQL, --runs 1: exit 0, vendor recorded as postgresql, fresh_import_no_attach skipped with the skip logged. On c9d7f950…: fresh_import 20.50 s, upgrade_import 24.42 s, drive_enumeration 0.01 s.

  • Annotation on that channel measured 362.11 s with the reset's VACUUM alone and 4.61 s with an ANALYZE after it — same channel, same revision, back to back. VACUUM reclaims the dead tuples but leaves the planner's statistics describing the rows the reset deleted, and annotation plans its large UPDATEs against them.
  • Postgres is still not a cross-vendor comparison axis; sqlite on low-end hardware is what this script exists to protect.

Verdict verification, c9d7f950… at --runs 3: a temporary time.sleep(0.5) in update_content_metadata moved annotation +21.0% against an unpatched baseline, left every import phase within 1.7%, and gave OVERALL VERDICT: FAIL, exit 1.

AI usage

Used Claude Code to write the script and take the captures above, with the fixture and phase choices directed by me. Verified with repeated sqlite and PostgreSQL captures and with prek.

Deviations from the issue spec

  • The issue predicts c9d7f950… importing through NoIncludedPresetsChannelImport with ContentNode on the ATTACH path. It does not: initialize_import_manager picks the class from the channel's min_schema_version, which is 1 on both fixtures, so both import through NoLearningActivitiesChannelImport and neither ATTACHes ContentNode. fresh_import_no_attach is therefore near-flat on wall clock on both channels, and the separated SQLAlchemy statement counts are what show the phase took effect. No published channel available today changes this.

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-07-30 23:12 UTC

@github-actions github-actions Bot added DEV: backend Python, databases, networking, filesystem... SIZE: large labels Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@rtibblesbot
rtibblesbot marked this pull request as ready for review July 30, 2026 21:33
rtibblesbot and others added 4 commits July 30, 2026 16:06
Times the content operations that the SQLAlchemy removal will touch, so
later pull requests can show a measured before/after rather than an
assurance. Five phases per channel against two real channels: fresh
import, fresh import with the SQLite ATTACH path disabled, annotation,
upgrade import and drive enumeration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VACUUM the destination database at the end of each content-table reset.
Without it every iteration imports into a slightly more degraded database
than the last, and the drift is systematic rather than random: annotation
climbed monotonically from 2.81s to 3.17s over six consecutive sqlite
iterations. Under the before/after protocol the branch is always captured
second, so that drift lands entirely on the branch and failed a no-change
self-comparison against the default 5% threshold.

Stop with the schema version named when a channel resolves to no import
class, rather than dying minutes later on a NoneType call.

Fail the run when import_channel_from_local_db returns False. It returns
rather than raises when check_and_delete_existing_channel finds nothing to
do, so the phase would record a near-zero mean and --compare would read it
against a real baseline as an enormous improvement and pass it. This
harness exists to measure modified import code, so an import that quietly
stops importing is the failure it most needs to refuse to report.

Refuse a --compare path equal to --output, which would overwrite a
baseline that costs hours to capture.
Fetch, describe and stage both channels before timing either of them. Studio
no longer publishing a channel id, and a schema with no import class, are both
knowable the moment a channel database is in hand; discovering either after
the first channel has been benchmarked in full throws hours of work away, for
the same reason main() already reads the baseline before the run.

Log comparison warnings at WARNING level and outside the --quiet gate. Every
one of them says the comparison measured something other than what was asked
for, and the two that invalidate it outright — a cross-vendor comparison, and
two captures taken at the same git revision — were being suppressed by a flag
documented as suppressing results and the comparison table.

Warn when the two captures ran under different Python versions. The report
already records it, and a git checkout does not guarantee one interpreter.

Reject --runs below 1, which otherwise downloads both channel databases and
writes a report with no phases in it, exiting 0.

Report an unmeasurable baseline against a measurable current run as an
unbounded regression rather than as 0%, which would have passed it.

Say in the docstring that a resumed partial download is cached unverified,
and that the reset budget now carries a VACUUM on top of the cascade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The postgres annotation phase measured 362s against under 3s for the same
channel on sqlite. VACUUM was not the fix: it reclaims the dead tuples but
leaves the planner's statistics describing the rows the reset deleted, so
annotation planned its large UPDATEs against them. ANALYZE after the VACUUM
takes that phase to 4.6s.

Create KOLIBRI_HOME and its parents before handing it to kolibri.utils.conf,
which only creates one level and otherwise raises "The parent of your
KOLIBRI_HOME does not exist". The default's parent is ~/.cache, which is not
present on every platform.

Correct the docstring's reason for running from the repository root: cwd does
not decide which kolibri is imported, but git_revision is read from it.

Record the measured --runs 3 stability figures, and the signature of a
contended capture — every phase moving by a similar amount in the same
direction — which no number of runs can average away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Seems to be working as intended, we'll run into any problems in the implementation phase, no doubt.

@rtibbles
rtibbles merged commit 319b33b into learningequality:develop Aug 4, 2026
91 checks passed
@rtibblesbot
rtibblesbot deleted the issue-15097-d36e60 branch August 4, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: backend Python, databases, networking, filesystem... SIZE: very large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants