Skip to content

docs(cohort-4): spec the ContentFile scoped-pull trigger contract - #2582

Open
blarghmatey wants to merge 7 commits into
mainfrom
spec/contentfile-scoped-pull
Open

blarghmatey wants to merge 7 commits into
mainfrom
spec/contentfile-scoped-pull

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

What are the relevant tickets?

MIT Learn ETL → OL Data Platform migration, Cohort 4: https://github.com/mitodl/hq/issues/11513

Resolves the open design question tracked as tk-define-contentfile-dagster-mit-learn-trigger-mec-8641eb.

Description (What does it do?)

Adds docs/design/contentfile_scoped_pull_contract.md, specifying how Dagster tells MIT Learn that extracted ContentFiles are ready, now that Cohort 4 moves Tika extraction to the platform. Docs only — no code.

The question the task asked was already answered, but not the one that mattered. It framed the decision as "(a) webhook from platform to Learn vs (b) Learn polls Dagster asset metadata". (a) shipped some time ago and runs in production from three Dagster locations:

Sender Partitioned by
dg_projects/openedx/openedx/assets/openedx.py:725 partitions_def
dg_projects/edxorg/edxorg/assets/openedx_course_archives.py:340 course_and_source_partitions
dg_projects/canvas/canvas/assets/canvas.py:294 canvas_course_ids (DynamicPartitionsDefinition)

All three POST {course_id, course_readable_id, content_path, source} to /api/v1/webhooks/content_files/, and MIT Learn responds by firing ingest_edx_run_archive — i.e. "go extract this archive yourself." Cohort 4 inverts that. So the real question is what the message should mean once the platform does the extracting.

Things worth a reviewer's attention

New endpoint, not a reinterpretation. All three senders share one path, so changing process_create_content_file_request would flip openedx, edxorg and Canvas simultaneously — no per-source rollback. Keeping them independent under the old path means branching per ETLSource, which is the same dual-meaning problem relocated. A new path lets sources migrate one at a time with the old one still serving whatever hasn't moved. Canvas migrates too, so the old path retires rather than living on.

The part that needs genuinely new code is scoping, and it is the risky part. BaseWarehouseETLTask (from mitodl/mit-learn#3566) has two modes and neither fits:

  • full_refresh=True → prunes; "the only mode that ever sees deletes/unpublishes upstream"
  • full_refresh=False → never prunes; "a partial pull must never be treated as the complete state of the source"

A per-course trigger is neither. Within one course it is the complete state — a removed file must be unpublished — but globally it is not, and pruning globally from a single-course pull would unpublish the entire corpus. §4 specifies a third mode whose prune predicate is scoped to the same key as the fetch, plus a published-row-count guard so a short read skips pruning instead of mass-unpublishing. This is the same full-sync hazard as MIN_PODCASTS/MIN_EPISODES in #2579, one level down.

§5 records a constraint that has already caused an outage. openedx.py:196 documents it: per-partition AutomationCondition evaluation on a 3,500-partition asset produced O(N²) load against the LMS and saturated the run queue until no export ran at all. The spec turns that into a rule — per-partition conditions only where evaluating them is per-partition and cheap; otherwise one sweeping sensor batching into a single N-element POST. Hence the payload is a list even for one course.

§8 flags a gap independent of the decision: there is no OCW content-file sender anywhere in dg_projects and no OCW dg project. OCW's only Learn-facing model is the course-level integrations__learn__ocw_courses. Whatever is chosen above, OCW needs a new sending asset built.

How can this be tested?

Not applicable — documentation only, no code paths. pre-commit run --files docs/design/contentfile_scoped_pull_contract.md passes: trailing-whitespace, end-of-files, added-large-files, merge-conflicts and detect-secrets all run on it and pass.

Every code reference in the doc was read from source at origin/main on 2026-08-19 rather than recalled: the three sender call sites and their partitions_defs, learn_api.py:55, webhooks/urls.py, webhooks/views.py:162/:179, the BaseWarehouseETLTask docstring, and the openedx.py:196 note quoted verbatim in §5.

Additional Context

Decisions this encodes, made by @tmacey: Option C (new endpoint, scoped pull), Canvas migrates with the others, and cadence is set per asset via automation conditions / freshness policies rather than a fixed cron.

Three questions are deliberately left open at the end of the doc rather than settled unilaterally — abort-vs-skip-prune on count mismatch, a batch-size ceiling, and whether the expected count belongs in the task signature or is re-read from the view.

Neither warehouse view (integrations__learn__ocw_content_files, integrations__learn__content_files) exists yet; §9 states the minimum columns they must expose.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VcWh3q3Bi2mxBmaiPCbBW9

Resolves the open design question for Cohort 4. The task framed it as
"webhook vs Learn polling", but the webhook shipped some time ago and runs in
production from three Dagster locations -- so the real question was what that
webhook should mean once the platform, rather than MIT Learn, does the Tika
extraction.

Specs a new endpoint rather than reinterpreting the existing one. All three
senders share one path, so changing the handler's behaviour would flip openedx,
edxorg and Canvas at once, with no per-source rollback; a new path lets sources
move one at a time and makes the coexistence explicit in routing instead of
hidden in a per-ETLSource branch.

The part that needs new code is scoping. BaseWarehouseETLTask has two modes --
full refresh, which prunes, and incremental, which deliberately never prunes
because a partial pull is not the complete state of the source. A per-course
trigger is neither: within one course it IS complete and a removed file must be
unpublished, but pruning globally from a single-course pull would unpublish the
whole corpus. The spec calls for a third mode whose prune predicate is scoped to
the same key as the fetch, plus a published-row-count guard -- the podcast
full-sync hazard at a different granularity.

Also records the O(N^2) automation-condition outage already documented in
openedx.py as a hard constraint on the per-asset cadence choice, and flags that
OCW has no sender at all and needs one built regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcWh3q3Bi2mxBmaiPCbBW9
Copilot AI balanced review requested due to automatic review settings August 19, 2026 20:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Documents the proposed ContentFile scoped-pull contract for Cohort 4.

Changes:

  • Defines the new webhook payload and receiver workflow.
  • Specifies scoped pruning safeguards and scheduling constraints.
  • Documents migration sequencing and the missing OCW sender.
Suppressed comments (1)

docs/design/contentfile_scoped_pull_contract.md:141

  • Section 8 says there is no OCW sender, so “all four senders are already partitioned” is incorrect and could cause the new OCW asset to omit an explicit partitioning/batching design.
Cadence is chosen per sending asset via Dagster automation conditions / freshness policies, not a
hardcoded cron. All four senders are already partitioned per course, so per-partition triggering
needs no restructuring.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
Comment thread docs/design/contentfile_scoped_pull_contract.md
@blarghmatey

Copy link
Copy Markdown
Member Author

Constraint for §9, surfaced reviewing #2589 (#2589 (comment)): integrations__learn__content_files has to extract the text out of raw_xml itself for problem and html blocks.

MIT Learn's documents_from_olx gates on VALID_TEXT_FILE_TYPES, which includes .xml, and every match goes through Tika via _extract_content — so what Learn stores for a problem today is extracted text, not markup. The platform side never sees those files: process_course_xml_blocks collects only non-XML members into the static-asset bundle, and course XML travels as blocks with raw_xml intact. Nothing in the extraction assets touches them.

So unless the view derives text from raw_xml, problem and html text disappears at cutover even though both systems have the same bytes.

Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
Comment thread docs/design/contentfile_scoped_pull_contract.md
Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
Comment thread docs/design/contentfile_scoped_pull_contract.md
Comment thread docs/design/contentfile_scoped_pull_contract.md
The prune predicate named run.learning_resource.readable_id, but that's
course-level and would unpublish every other run's files — confirmed
against mit-learn's process_create_content_file_request and
ContentFile.objects.filter(run=course_run, ...), the scope key MIT
Learn actually prunes on is run.run_id (sent today as
course_readable_id). Added etl_source alongside it since run_id alone
collides across sources.

Also: BaseWarehouseETLTask moved from the closed mit-learn#3566 (Trino)
to merged mit-learn#3807 (StarRocks) — updated the PR link and engine
name, and flagged that iter_rows/run() don't support a scope predicate
yet. Fixed line-number drift on four other citations and corrected "all
four senders" to three (OCW has no sender yet, per §8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGqjZ3r5u7kpyTLRMwwLdJ
Comment thread docs/design/contentfile_scoped_pull_contract.md
…trictness

Canvas: the new sender computes readable_id itself (course_id +
course_code, matching Learn's f"{course_folder}-{course_code}"
derivation confirmed in etl/canvas.py:138 and canvas_course_folder()),
rather than depending on Canvas's sis_course_id being set the way the
existing webhook does today.

Count guard: any content_file_count mismatch skips the prune, not just
a "material" one — both numbers come from the same publish event, so a
gap is a read-consistency race (StarRocks/Iceberg catalog lag, a dbt
rebuild in flight), not a real content change, and the wrong-direction
error (unpublishing files that still exist) is worse than pruning
stalling. Left the zero-file-course override as an explicit open
question since the strict guard as written blocks that case too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGqjZ3r5u7kpyTLRMwwLdJ
Comment thread docs/design/contentfile_scoped_pull_contract.md Outdated
blarghmatey and others added 4 commits September 4, 2026 14:04
Self-heal: a daily reconciliation sweep on the Learn side replaces the
retiring Celery beat sweeps as the backstop against a dropped webhook
POST. Chose Learn-side over a platform-side re-announce because the
sweep is now just a SQL pull against an already-materialized warehouse
view, not a re-triggered extraction, so it doesn't hit the O(N^2)
per-partition hazard §5 documents.

Prune race: serialize scoped pulls per readable_id with a lock, rather
than a staleness check against a payload timestamp, to stop an
earlier-started pull's stale prune from unpublishing a later pull's
upserts for the same run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGqjZ3r5u7kpyTLRMwwLdJ
readable_id in §9's schema meant the payload's course/run-level field
and the view's own per-row identity at once, since this view is one
row per file. Renamed to run_readable_id, keeping key as the file
identity. Also added edx_module_id, source_path, file_extension, and
checksum, confirmed present on ContentFile in mit-learn's models.py,
since MIT Learn persists all four today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGqjZ3r5u7kpyTLRMwwLdJ
The platform, not Learn, needs to hear about an OCW publish once the
platform owns extraction — narrowed to Concourse calling a new
platform-side endpoint, or a Dagster sensor on the OCW S3 bucket, and
set aside polling OCW Studio's publish_date to stay closer to today's
push-based, low-latency model. Flagged that whichever is chosen still
needs to cover unpublish, which the existing webhook handles
immediately and which the §7 daily sweep is too slow to replace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGqjZ3r5u7kpyTLRMwwLdJ
…ble_id

Sentry flagged that the readable_id formula named course_folder without
saying how the sender gets it. Learn's own canvas_course_folder()
docstring says that value is the Canvas course id, just reached via the
archive's S3 key path today — so the sender's formula is
f"{course_id}-{course_code}" using fields it already has, no separate
course_folder lookup needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGqjZ3r5u7kpyTLRMwwLdJ
@blarghmatey

Copy link
Copy Markdown
Member Author

Investigated further — this needs a capa/OLX-aware tree-walk over the parsed block XML that splits student-safe question text from embedded <solution>/answer content, rather than generic Tika tag-stripping (which doesn't distinguish the two and is what today's Learn-side extraction already does, unnoticed). Traced the full blast radius across ol-data-platform, mit-learn, and learn-ai's AI tutor and wrote it up as an RFC: https://github.com/mitodl/hq/discussions/13220

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants