Skip to content

fix(raport_slotow): strumieniowy eksport XLSX + limit wierszy (2.7)#652

Open
mpasternak wants to merge 2 commits into
devfrom
fix/xlsx-export-memory
Open

fix(raport_slotow): strumieniowy eksport XLSX + limit wierszy (2.7)#652
mpasternak wants to merge 2 commits into
devfrom
fix/xlsx-export-memory

Conversation

@mpasternak

Copy link
Copy Markdown
Member

Problem

Eksport XLSX materializował cały dataset, kopiował do openpyxl, czytał jako
bytes — trzy kopie w RAM, bez limitu wierszy, synchronicznie w cyklu
request/response. Ryzyko OOM/DoS (poz. 2.7 audytu).

Zmiana

Wspólny chokepoint MyTableExport.export_xlsx (obejmuje ścieżki: autor,
ewaluacja, zerowy, upowaznienie_pbn):

  • usunięta kopia pośrednia tablib (strumień table.as_values()),
  • io.BytesIO zamiast NamedTemporaryFile,
  • limit 10000 wierszy sprawdzany COUNT-em przed iteracją; przekroczenie
    ExportRowLimitExceededHTTP 400 z jasnym komunikatem (bez cichego
    ucięcia
    — reguła repo).

Zawartość XLSX niezmieniona (te same kolumny/dane/tabela/SUBTOTAL). Ścieżka async
(uczelnia, przez celery) nietknięta funkcjonalnie (bramka wyłączona
export_max_rows=None, ale korzysta z odchudzonego budowania).

Zakres i follow-up

Odłożone jako inny wzorzec (nie ten sam chokepoint): nowe_raporty
(flexible_reports databook) i ewaluacja_optymalizacja/exports.py (openpyxl
wprost + ZIP). FOLLOW-UP (znaleziony w review, poza zakresem): eksport
szczegółów uczelni biegnie synchronicznie BEZ limitu — potencjalnie największa
powierzchnia OOM, do osobnej decyzji (bramka albo tło).

Weryfikacja

  • test_export_xlsx.py 12 passed (charakteryzacyjne + limit), selekcja 172 passed.
  • Testy czytają zawartość (openpyxl), NIE porównują bajtów (niedeterministyczne
    dcterms:created).
  • Review PASS + APPROVED (limit bez cichego ucięcia, zawartość niezmieniona,
    async nietknięty). Dopisany komentarz: bramka zależy od paginacji tabeli.

Poz. 2.7 audytu.

🤖 Generated with Claude Code

https://claude.ai/code/session_019GmViAsaif9MXeuDMA5NHX

mpasternak and others added 2 commits July 20, 2026 21:52
…2.7)

MyTableExport.export_xlsx trzymał trzy kopie datasetu w RAM naraz
(tablib.Dataset -> openpyxl.Workbook -> bytes z NamedTemporaryFile) bez
limitu wierszy, synchronicznie w cyklu request/response -> ryzyko OOM/DoS
przy dużym eksporcie (poz. 2.7 audytu).

Wzorzec z bpp/views/mymultiseek.py (limit + strumieniowanie):

- Pomijamy budowę pełnego tablib.Dataset — strumieniujemy table.as_values()
  (generator) prosto do openpyxl (jedna kopia mniej).
- NamedTemporaryFile (round-trip przez dysk) zastąpione io.BytesIO.
- Limit RAPORT_SLOTOW_EXPORT_MAX_ROWS=10000 sprawdzany PRZED iteracją przez
  len(table.rows) (COUNT, bez materializacji). Przekroczenie -> wyjątek
  ExportRowLimitExceeded łapany na granicy widoku i zamieniany na HTTP 400
  z czytelnym komunikatem (bez cichego ucięcia).

Objęte ścieżki synchroniczne: autor, ewaluacja, zerowy, upowaznienie_pbn.
Raport uczelniany (generacja async przez liveops/celery) ma bramkę wierszy
wyłączoną (export_max_rows=None), zachowuje zachowanie, korzysta z
odchudzonego budowania.

Zawartość i format pliku XLSX bez zmian — zmienia się tylko sposób budowy.
Testy charakteryzacyjne pinują zawartość komórek; nowe testy sprawdzają
zadziałanie limitu (bez porównywania bajtów — niedeterministyczny
dcterms:created).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GmViAsaif9MXeuDMA5NHX
Recenzja wskazała utajoną krawędź: len(self.table.rows) daje COUNT tylko
dla tabeli paginowanej; bez paginatora degraduje do len(self.data) =
pełna materializacja przed bramką, niwecząc ochronę OOM. Wszystkie obecne
widoki z max_rows są paginowane; dopisano ostrzeżenie dla przyszłych.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GmViAsaif9MXeuDMA5NHX
Comment thread src/raport_slotow/util.py
try:
return exporter.response(filename=self.get_export_filename(export_format))
except ExportRowLimitExceeded as e:
return HttpResponseBadRequest(str(e))
filename=self.get_export_filename(export_format, n)
)
except ExportRowLimitExceeded as e:
return HttpResponseBadRequest(str(e))
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