Skip to content

Add per-source ETL write-ownership guard (pull vs push)#3565

Open
blarghmatey wants to merge 2 commits into
mainfrom
tk-b7efd0-etl-ownership-guard
Open

Add per-source ETL write-ownership guard (pull vs push)#3565
blarghmatey wants to merge 2 commits into
mainfrom
tk-b7efd0-etl-ownership-guard

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

What are the relevant tickets?

N/A (internal SOA remediation backlog item; no GitHub issue)

Description (What does it do?)

Batch pull-ETL loaders implement full-sync unpublish: load_courses prunes anything not in the current batch to published=False (learning_resources/etl/loaders.py), same for load_programs, and the canvas sync task hard-deletes stale courses (learning_resources/tasks.py). If a pull-ETL (Celery) pipeline and a push pipeline (webhook/Dagster asset) both write the same (etl_source, resource_type), each full sync treats the other's writes as missing, causing unpublish/republish flapping. There was no source-of-truth flag to prevent this.

This PR adds a per-(etl_source, resource_type) write-ownership guard:

  • New ETLSourceOwnership model (etl_source, resource_type, mode: pull|push). A missing row defaults to pull, so no existing source's behavior changes.
  • New learning_resources/etl/ownership.py with pull_write_allowed/is_push_owned/assert_pull_allowed/assert_push_allowed helpers.
  • load_courses and load_programs now short-circuit (no writes, no prune) for a push-owned pair.
  • The canvas stale-course sweep in sync_canvas_courses skips its prune block for a push-owned pair.
  • assert_push_allowed is added as the guard primitive for the upcoming generic learning_resources webhook loader (Add generic /api/v1/webhooks/learning_resources/ endpoint (Cohort 2 webhook delivery) #3557) to call before writing, so a push pipeline can't write into a pair pull-ETL still owns.
  • Registered ETLSourceOwnership in Django admin so ownership can be flipped per source without a deploy.

This is a prerequisite for migrating any source from pull to push — it does not cut over any source. SEE/Sloan is the first migration candidate (already dual-modeled as a Dagster asset) but no ETLSourceOwnership row is seeded here.

How can this be tested?

  • New tests: learning_resources/etl/ownership_test.py (default-pull behavior, push override is scoped to the exact (etl_source, resource_type) pair, assert_pull_allowed/assert_push_allowed raise correctly).
  • New tests: test_load_courses_skips_write_when_push_owned / test_load_programs_skips_write_when_push_owned in learning_resources/etl/loaders_test.py — assert no DB writes and no prune occur, and existing published resources are untouched, when a pair is push-owned.
  • New test: test_sync_canvas_courses_skips_stale_prune_when_push_owned in learning_resources/tasks_test.py.
  • Ran the full learning_resources/etl/loaders_test.py, learning_resources/tasks_test.py, and learning_resources/etl/pipelines_test.py suites locally against Postgres — 406 passed, no regressions.
  • Verified the hand-written migration (0116_etlsourceownership.py) matches Django's expected model state via manage.py makemigrations --check --dry-run (no diff detected).
  • ruff check / ruff format --check clean on all new/changed files (pre-existing missing-docstring findings on unrelated pre-existing code left as-is, matching repo convention).

Reviewer validation: create an ETLSourceOwnership(etl_source="see", resource_type="course", mode="push") row via /admin/, then run python manage.py backpopulate_sloan_data (or trigger get_sloan_data) — it should log a skip and make no writes/prunes to SEE courses.

Additional Context

Internal tracking: tk-mit-learn-per-source-ownership-guard-before-any--b7efd0 in the SOA simplification & boundary-hardening remediation backlog. Evidence audit: pf-soa-mit-learn-etl-data-ownership-audit-2026-07-d-8e9b00.

Pull-ETL batch loaders (load_courses, load_programs, canvas stale
sweep) full-sync prune/unpublish anything not present in the current
batch. If a push pipeline (webhook or Dagster asset) also writes the
same (etl_source, resource_type), each side treats the other's writes
as missing, causing unpublish/republish flapping. There was no
source-of-truth flag.

Add ETLSourceOwnership (etl_source, resource_type, mode: pull|push),
defaulting to pull so existing sources are unaffected. Wire
pull_write_allowed into load_courses/load_programs (skip write+prune
entirely for a push-owned pair) and the canvas stale-course sweep
(skip prune only). Add assert_push_allowed for the upcoming generic
learning_resources webhook loader (PR #3557) to call before writing.

This is the prerequisite for cutting any source (SEE/Sloan first,
already dual-modeled as a Dagster asset) from pull to push; no source
is cut over by this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 2, 2026 22:16
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

OpenAPI Changes

No changes detected

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Comment thread learning_resources/tasks.py Outdated

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

Adds a per-(etl_source, resource_type) write-ownership guard so pull-based full-sync ETL loaders don’t prune/unpublish (or hard-delete) resources that are now owned by a push pipeline (e.g., webhook/Dagster), preventing “flapping” when both pipelines touch the same pair.

Changes:

  • Introduces ETLSourceOwnership model + admin registration to configure ownership as pull vs push per (etl_source, resource_type).
  • Adds learning_resources/etl/ownership.py helper API (pull_write_allowed, is_push_owned, and strict assert_*_allowed guards).
  • Updates course/program loaders and the Canvas stale-course sweep to skip writes/prunes when the pair is push-owned, with targeted test coverage.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.

Show a summary per file
File Description
learning_resources/tasks.py Skips Canvas stale-course prune when canvas/course is push-owned.
learning_resources/tasks_test.py Adds regression test ensuring stale Canvas courses aren’t pruned when push-owned.
learning_resources/models.py Adds ETLSourceOwnership model to declare per-pair ownership mode.
learning_resources/migrations/0116_etlsourceownership.py Creates the ETLSourceOwnership table and uniqueness constraint.
learning_resources/factories.py Adds ETLSourceOwnershipFactory for tests.
learning_resources/etl/ownership.py Implements ownership lookup and enforcement helpers for pull vs push writers.
learning_resources/etl/ownership_test.py Tests default-pull behavior and both assertion guards.
learning_resources/etl/loaders.py Makes load_courses / load_programs no-op (no write/prune) when push-owned.
learning_resources/etl/loaders_test.py Adds tests verifying loaders do not write/prune when push-owned.
learning_resources/admin.py Registers ETLSourceOwnership in Django admin for runtime configuration.

The ownership check in sync_canvas_courses only guarded the stale-
course prune block, not the S3 archive ingestion loop above it, so a
push-owned canvas source would still receive pull-side writes -
defeating the point of the guard. Move the pull_write_allowed check
to the top of the function, matching load_courses/load_programs: skip
the entire sync (ingestion + prune) when push-owned.

Flagged by Sentry Seer's automated review on PR #3565.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

2 participants