perf: stream report uploads instead of buffering the whole file in memory - #38946
perf: stream report uploads instead of buffering the whole file in memory#38946blarghmatey wants to merge 2 commits into
Conversation
…mory
DjangoStorageReportStore.store() read its entire buffer into memory, re-encoded
it, and copied it into a ContentFile before handing it to the storage backend:
buff_contents = buff.read() # whole file into RAM
if not isinstance(buff_contents, bytes):
buff_contents = buff_contents.encode('utf-8') # second copy
buff = ContentFile(buff_contents) # third
self.storage.save(path, buff)
That put peak memory at roughly 3x the report size at the final step, and it
undid most of the benefit of TemporaryFileReportMixin: that mixin spills rows to
a temp file specifically to avoid holding the report in memory, then the upload
read the whole thing straight back in. The ReportStore docstring has anticipated
this for some time -- "Should probably refactor later to create a ReportFile
object that can simply be appended to for the sake of memory efficiency".
A binary buffer is now handed to the backend as-is, so it streams:
S3Boto3Storage uploads in parts, FileSystemStorage writes in chunks, and peak
memory is one chunk rather than the whole report.
Text buffers still work, and still have to be read in full because the backends
require bytes. Both in-tree callers that pass text are unaffected in behaviour;
the type is probed with a zero-length read rather than by inspecting mode
attributes, which are not consistently present across the raw file objects,
ContentFile and BytesIO instances that callers pass.
Two callers move to binary so they take the streaming path:
* TemporaryFileReportMixin now opens its temp files 'w+b' and writes through a
TextIOWrapper, which is what makes the on-disk grade report actually bounded
end to end rather than only during row generation.
* store_rows() builds its CSV in a BytesIO by the same means.
Both wrappers use newline='' per the csv module's contract -- the writer emits
its own line terminators and must not have them translated again -- and are
detached rather than closed, so the underlying file outlives the wrapper.
Tests assert the streaming behaviourally rather than by inspecting call args: a
streaming backend reads in bounded chunks, so an unbounded read() is the
signature of the report being slurped into RAM. Added to ReportStoreTestMixin so
they run against all three configurations, including the S3 stub. Round-trip
tests cover the binary, text and store_rows paths; CSV bytes are unchanged.
Completes the last of the five findings in the issue.
Refs: openedx#38943
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ki1NYMjSjVB4uz1gdgsjBh
|
Thanks for the pull request, @blarghmatey! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
…t store test Upstream's pylint config flags C1803 on `unbounded_reads == []`. Switched to a truthiness check and added the recorded reads to the assertion message, so a failure names which unbounded read happened rather than just reporting False. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ki1NYMjSjVB4uz1gdgsjBh
|
@openedx/wg-maintenance-openedx-platform-oncall — this is ready for engineering review, with one caveat on the build noted below. Checklist from the bot above:
The coverage failure is an artifact flake, not this PR
Evidence that this is infrastructure rather than the diff:
I can't re-run it myself — Short version of the change
Binary buffers now stream to the backend, and the two callers that can supply binary (the temp-file mixin and Related: #38944 covers the other four findings from #38943. Independent of this one — different parts of |
|
Just as a sanity check:
|
What
Finding 4 of #38943, the piece deliberately left out of #38944 because it touches shared
storage code used by every instructor-task report rather than only the grade reports.
Why
DjangoStorageReportStore.store()read its whole buffer into memory, re-encoded it, andcopied it into a
ContentFilebefore handing it to the storage backend:Peak memory at the final step was therefore roughly 3x the report size — and it largely
defeated
TemporaryFileReportMixin, which exists precisely to keep the report out ofmemory. That mixin spills rows to a temp file, and then the upload read the whole thing
straight back in. So enabling
instructor_task.use_on_disk_grade_reportingbounded rowgeneration but left a full-size spike at upload.
The
ReportStoredocstring has anticipated this for a while:Changes
A binary buffer is now passed to the backend as-is, so it streams —
S3Boto3Storageuploads in parts,
FileSystemStoragewrites in chunks, and peak memory is one chunkrather than the whole report.
Text buffers still work and still have to be read in full, because the backends require
bytes. Both in-tree callers that pass text (
instructor/views/api_v2.py, and the existingStringIOusage in tests) are unchanged in behaviour. The type is probed with azero-length read rather than by inspecting mode attributes, which are not consistently
present across the raw file objects,
ContentFileandBytesIOinstances that callerspass.
Two callers move to binary so they take the streaming path:
TemporaryFileReportMixinopens its temp files'w+b'and writes through aTextIOWrapper. This is what makes the on-disk grade report bounded end to end ratherthan only during row generation.
store_rows()builds its CSV in aBytesIOby the same means.Both wrappers use
newline=''per the csv module's contract — the writer emits its ownline terminators and must not have them translated again — and are detached rather than
closed, so the underlying file outlives the wrapper.
Testing
lms/djangoapps/instructor_task/tests/— 252 passed, up from 243, the nine newbeing three tests across the three report-store configurations.
test_api_helper.py::ScheduledBulkEmailInstructorTaskTestsarepre-existing: they fail identically on unmodified
masterat387ee5d9(verifiedby stashing this branch's changes and re-running). Unrelated to this change — scheduled
bulk-email task creation, not report storage.
The new tests assert the streaming behaviourally rather than by inspecting call args. A
streaming backend reads in bounded chunks (
File.chunksandupload_fileobjboth pass anexplicit size), so an unbounded
read()is the signature of the report being slurped intoRAM. They live in
ReportStoreTestMixin, so they run against all three configurationsincluding the S3 stub, plus round-trip assertions for the binary, text and
store_rowspaths.
Backward compatibility
CSV bytes are unchanged.
store()keeps accepting text buffers, so no caller in or out oftree needs to change. No schema or data migration.
Notes for reviewers
instructor/views/api_v2.pystill passes a textStringIOfor the issued-certificatesreport. It works and is correct; it just does not get the streaming benefit. Left alone
to keep this diff to the storage layer, and that report is small.
of
grades.pyand can land in either order.