Skip to content

Commit 92c1f8f

Browse files
authored
Ingest contentfile archives for all runs (two-tier indexing) (#3503)
1 parent 4ca703e commit 92c1f8f

17 files changed

Lines changed: 1014 additions & 256 deletions

learning_resources/etl/constants.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ class ETLSource(ExtendedEnum):
9595
ovs = "ovs"
9696

9797

98+
QDRANT_RETAINED_SOURCES = (
99+
ETLSource.mit_edx.value,
100+
ETLSource.mitxonline.value,
101+
ETLSource.xpro.value,
102+
ETLSource.oll.value,
103+
ETLSource.canvas.value,
104+
)
105+
106+
98107
class CourseNumberType(Enum):
99108
"""Enum of course number types"""
100109

learning_resources/etl/edx_shared.py

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ def build_run_lookup(
7777
Args:
7878
etl_source(str): The ETL source
7979
ids(list of int): List of LearningResource IDs to filter by.
80-
If empty/falsy, all published/test_mode runs for the source
81-
are included.
80+
If empty/falsy, all runs of every published/test_mode course
81+
for the source are included.
8282
8383
Returns:
8484
dict: Mapping of normalized run_id -> list of LearningResourceRun
@@ -87,7 +87,6 @@ def build_run_lookup(
8787
LearningResourceRun.objects.filter(
8888
learning_resource__etl_source=etl_source,
8989
)
90-
.filter(Q(published=True) | Q(learning_resource__test_mode=True))
9190
.filter(
9291
Q(learning_resource__published=True) | Q(learning_resource__test_mode=True)
9392
)
@@ -107,6 +106,9 @@ def build_run_lookup(
107106
for run in runs:
108107
normalized = normalize_run_id(etl_source, run.run_id)
109108
lookup.setdefault(normalized, []).append(run)
109+
if etl_source == ETLSource.oll.name:
110+
# OLL archive filenames omit the MITx+ prefix the run_ids carry
111+
lookup.setdefault(normalized.removeprefix("mitx."), []).append(run)
110112
return lookup
111113

112114

@@ -256,10 +258,12 @@ def sync_edx_archive(
256258
trigger_resource_etl(etl_source)
257259
return
258260
course = run.learning_resource
259-
if course.published and not course.test_mode and course.best_run != run:
260-
# This is not the best run for the published course, so skip it
261+
if not course.published and not course.test_mode:
262+
# Fully-retired course; skip it
261263
log.warning(
262-
"%s not the best run for %s, skipping", run.run_id, course.readable_id
264+
"%s belongs to a retired course %s, skipping",
265+
run.run_id,
266+
course.readable_id,
263267
)
264268
return
265269
bucket = get_bucket_by_name(settings.COURSE_ARCHIVE_BUCKET_NAME)
@@ -278,14 +282,10 @@ def run_for_edx_archive(etl_source: str, archive_filename: str):
278282
LearningResourceRun or None: The matching run, or None if not found
279283
"""
280284
normalized_run_id = extract_run_id_from_key(etl_source, archive_filename)
281-
runs = (
282-
LearningResourceRun.objects.filter(
283-
learning_resource__etl_source=etl_source,
284-
)
285-
.filter(Q(published=True) | Q(learning_resource__test_mode=True))
286-
.filter(
287-
Q(learning_resource__published=True) | Q(learning_resource__test_mode=True)
288-
)
285+
runs = LearningResourceRun.objects.filter(
286+
learning_resource__etl_source=etl_source,
287+
).filter(
288+
Q(learning_resource__published=True) | Q(learning_resource__test_mode=True)
289289
)
290290
if etl_source in (ETLSource.mit_edx.name, ETLSource.oll.name):
291291
runs = runs.filter(run_id__iregex=normalized_run_id)
@@ -327,10 +327,4 @@ def sync_edx_course_files(
327327
log.warning("There are %d runs for %s", len(matching_runs), key)
328328

329329
run = matching_runs[0]
330-
course = run.learning_resource
331-
332-
if course.published and not course.test_mode and course.best_run != run:
333-
# This is not the best run for the published course, so skip it
334-
log.debug("Not the best run for %s, skipping", run.run_id)
335-
continue
336330
process_course_archive(bucket, key, run, overwrite=overwrite)

learning_resources/etl/edx_shared_test.py

Lines changed: 115 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,11 @@
3636
(ETLSource.oll.name, PlatformType.edx.name),
3737
],
3838
)
39-
@pytest.mark.parametrize("published", [True, False])
4039
def test_sync_edx_course_files(
4140
mock_course_archive_bucket,
4241
mocker,
4342
source,
4443
platform,
45-
published,
4644
): # pylint: disable=too-many-arguments,too-many-locals
4745
"""Sync edx courses from a tarball stored in S3"""
4846
mock_load_content_files = mocker.patch(
@@ -61,47 +59,69 @@ def test_sync_edx_course_files(
6159
"learning_resources.etl.edx_shared.get_bucket_by_name",
6260
return_value=mock_course_archive_bucket.bucket,
6361
)
62+
platform_obj = LearningResourcePlatformFactory.create(code=platform)
6463
courses = LearningResourceFactory.create_batch(
6564
2,
66-
platform=LearningResourcePlatformFactory.create(code=platform),
65+
platform=platform_obj,
6766
etl_source=source,
6867
is_course=True,
6968
published=True,
7069
create_runs=False,
7170
)
71+
# A fully-retired (unpublished, non-test_mode) course must be excluded
72+
retired_course = LearningResourceFactory.create(
73+
platform=platform_obj,
74+
etl_source=source,
75+
is_course=True,
76+
published=False,
77+
test_mode=False,
78+
create_runs=False,
79+
)
7280
keys = []
7381
s3_prefix = get_s3_prefix_for_source(source)
74-
for course in courses:
75-
runs = LearningResourceRunFactory.create_batch(
76-
2,
77-
learning_resource=course,
78-
published=published,
79-
)
80-
course.refresh_from_db()
81-
if published:
82-
assert course.best_run in runs
83-
keys.extend(
82+
83+
def add_keys_for_runs(runs):
84+
new_keys = (
8485
[f"{s3_prefix}/{run.run_id}/foo.tar.gz" for run in runs]
8586
if source != ETLSource.oll.name
8687
else [f"{s3_prefix}/{run.run_id}_OLL.tar.gz" for run in runs]
8788
)
88-
for key in keys:
89+
keys.extend(new_keys)
90+
for key in new_keys:
8991
with Path.open(
9092
Path("test_json/course-v1:MITxT+8.01.3x+3T2022.tar.gz"), "rb"
9193
) as infile:
92-
bucket.put_object(
93-
Key=key,
94-
Body=infile.read(),
95-
ACL="public-read",
96-
)
97-
sync_edx_course_files(source, [course.id for course in courses], keys)
98-
# Only best runs for published courses are processed, so 2 runs (one per course) not 4
99-
expected_calls = 2 if published else 0
100-
assert mock_transform.call_count == expected_calls
101-
assert mock_load_content_files.call_count == expected_calls
102-
if published:
103-
for course in courses:
104-
mock_load_content_files.assert_any_call(course.best_run, fake_data)
94+
bucket.put_object(Key=key, Body=infile.read(), ACL="public-read")
95+
96+
for course in courses:
97+
runs = LearningResourceRunFactory.create_batch(
98+
2,
99+
learning_resource=course,
100+
published=True,
101+
)
102+
course.refresh_from_db()
103+
assert course.best_run in runs
104+
add_keys_for_runs(runs)
105+
106+
retired_runs = LearningResourceRunFactory.create_batch(
107+
2,
108+
learning_resource=retired_course,
109+
published=True,
110+
)
111+
add_keys_for_runs(retired_runs)
112+
113+
sync_edx_course_files(
114+
source, [course.id for course in [*courses, retired_course]], keys
115+
)
116+
# All runs of published courses are processed; retired-course runs are excluded
117+
assert mock_transform.call_count == 4
118+
assert mock_load_content_files.call_count == 4
119+
loaded_runs = {call.args[0] for call in mock_load_content_files.call_args_list}
120+
for course in courses:
121+
for run in course.runs.all():
122+
assert run in loaded_runs
123+
for run in retired_runs:
124+
assert run not in loaded_runs
105125
mock_log.assert_not_called()
106126

107127

@@ -692,24 +712,23 @@ def test_sync_edx_archive_no_run_found(mocker, mock_course_archive_bucket, etl_s
692712

693713

694714
@pytest.mark.parametrize("etl_source", [ETLSource.mitxonline.name, ETLSource.xpro.name])
695-
def test_sync_edx_archive_not_best_run(mocker, mock_course_archive_bucket, etl_source):
696-
"""Test sync_edx_archive skips processing when run is not the best run"""
715+
def test_sync_edx_archive_non_best_run_processed(
716+
mocker, mock_course_archive_bucket, etl_source
717+
):
718+
"""sync_edx_archive now processes a non-best run of a published course."""
697719
from learning_resources.etl.edx_shared import sync_edx_archive
698720

699721
platform = (
700722
PlatformType.mitxonline.name
701723
if etl_source == ETLSource.mitxonline.name
702724
else PlatformType.xpro.name
703725
)
704-
705-
# Create a course with multiple runs
706726
course = LearningResourceFactory.create(
707727
platform=LearningResourcePlatformFactory.create(code=platform),
708728
etl_source=etl_source,
709729
published=True,
710730
create_runs=False,
711731
)
712-
# Create older run (not best) with earlier start date
713732
from datetime import UTC, datetime
714733

715734
old_run = LearningResourceRunFactory.create(
@@ -718,43 +737,79 @@ def test_sync_edx_archive_not_best_run(mocker, mock_course_archive_bucket, etl_s
718737
run_id="course-v1:Test+Course+R1",
719738
start_date=datetime(2022, 1, 1, tzinfo=UTC),
720739
)
721-
# Create newer run (will be best) with later start date
722740
LearningResourceRunFactory.create(
723741
learning_resource=course,
724742
published=True,
725743
run_id="course-v1:Test+Course+R2",
726744
start_date=datetime(2023, 1, 1, tzinfo=UTC),
727745
)
728746
course.refresh_from_db()
729-
730-
# Verify the newer run is the best run
731747
assert course.best_run.run_id == "course-v1:Test+Course+R2"
732748

733-
# Archive is for the old run, not the best run
734749
bucket = mock_course_archive_bucket.bucket
735750
s3_key = "20220101/courses/course-v1:Test+Course+R1/abcdefghijklmnop.tar.gz"
736-
737751
with Path.open(
738752
Path("test_json/course-v1:MITxT+8.01.3x+3T2022.tar.gz"), "rb"
739753
) as infile:
740754
bucket.put_object(Key=s3_key, Body=infile.read(), ACL="public-read")
755+
mocker.patch(
756+
"learning_resources.etl.edx_shared.get_bucket_by_name", return_value=bucket
757+
)
758+
mock_process = mocker.patch(
759+
"learning_resources.etl.edx_shared.process_course_archive"
760+
)
741761

762+
sync_edx_archive(etl_source, s3_key, overwrite=False)
763+
764+
mock_process.assert_called_once()
765+
assert mock_process.call_args[0][2] == old_run
766+
767+
768+
@pytest.mark.parametrize("etl_source", [ETLSource.mitxonline.name, ETLSource.xpro.name])
769+
def test_sync_edx_archive_retired_course_skipped(
770+
mocker, mock_course_archive_bucket, etl_source
771+
):
772+
"""sync_edx_archive still skips runs of a fully-retired (unpublished, non-test_mode) course."""
773+
from learning_resources.etl.edx_shared import sync_edx_archive
774+
775+
platform = (
776+
PlatformType.mitxonline.name
777+
if etl_source == ETLSource.mitxonline.name
778+
else PlatformType.xpro.name
779+
)
780+
course = LearningResourceFactory.create(
781+
platform=LearningResourcePlatformFactory.create(code=platform),
782+
etl_source=etl_source,
783+
published=False,
784+
test_mode=False,
785+
create_runs=False,
786+
)
787+
LearningResourceRunFactory.create(
788+
learning_resource=course,
789+
published=False,
790+
run_id="course-v1:Test+Course+R1",
791+
)
792+
bucket = mock_course_archive_bucket.bucket
793+
s3_key = "20220101/courses/course-v1:Test+Course+R1/abcdefghijklmnop.tar.gz"
794+
with Path.open(
795+
Path("test_json/course-v1:MITxT+8.01.3x+3T2022.tar.gz"), "rb"
796+
) as infile:
797+
bucket.put_object(Key=s3_key, Body=infile.read(), ACL="public-read")
742798
mocker.patch(
743-
"learning_resources.etl.edx_shared.get_bucket_by_name",
744-
return_value=bucket,
799+
"learning_resources.etl.edx_shared.get_bucket_by_name", return_value=bucket
800+
)
801+
# No matching run is returned for a retired course, so ETL is triggered instead.
802+
mock_trigger = mocker.patch(
803+
"learning_resources.etl.edx_shared.trigger_resource_etl"
745804
)
746805
mock_process = mocker.patch(
747806
"learning_resources.etl.edx_shared.process_course_archive"
748807
)
749-
mock_log = mocker.patch("learning_resources.etl.edx_shared.log.warning")
750808

751809
sync_edx_archive(etl_source, s3_key, overwrite=False)
752810

753-
# Should log warning and not process
754-
assert mock_log.called
755-
assert "not the best run" in mock_log.call_args[0][0]
756-
assert old_run.run_id in mock_log.call_args[0][1]
757811
mock_process.assert_not_called()
812+
mock_trigger.assert_called_once_with(etl_source)
758813

759814

760815
@pytest.mark.parametrize("etl_source", [ETLSource.mitxonline.name, ETLSource.xpro.name])
@@ -1063,6 +1118,22 @@ def test_build_run_lookup(source, platform):
10631118
assert lookup[normalized][0].id == run.id
10641119

10651120

1121+
def test_build_run_lookup_oll_strips_mitx_prefix():
1122+
"""OLL runs are also indexed without the MITx prefix the archives omit"""
1123+
source = ETLSource.oll.name
1124+
course = LearningResourceFactory.create(
1125+
etl_source=source, published=True, create_runs=False
1126+
)
1127+
run = LearningResourceRunFactory.create(
1128+
learning_resource=course, run_id="MITx+0.501x+2T2019", published=True
1129+
)
1130+
lookup = build_run_lookup(source, [course.id])
1131+
# archive filenames like 0_501x_2T2019_OLL.tar.gz normalize without the prefix
1132+
assert "0.501x.2t2019" in lookup
1133+
assert lookup["0.501x.2t2019"][0].id == run.id
1134+
assert "mitx.0.501x.2t2019" in lookup
1135+
1136+
10661137
def test_build_run_lookup_filters_by_ids():
10671138
"""build_run_lookup only includes runs for the specified course ids"""
10681139
source = ETLSource.mitxonline.name

0 commit comments

Comments
 (0)