Implemented the memory fix.

Changed app.py:
•
process_pft_pdf() now uses the uploaded file stream directly instead of copying the PDF into bytes.
•
Output now uses SpooledTemporaryFile, so larger generated PDFs spill to disk instead of staying fully in RAM.
•
Explicitly closes PdfReader objects after writing.
•
Removed @app.teardown_request gc.collect(), which was running too early and could break send_file.
•
Added response.call_on_close() cleanup so the generated PDF stream is closed only after Flask/Werkzeug finishes sending it.
Changed Dockerfile:
•
Added Gunicorn --max-requests 50 --max-requests-jitter 10 as a production guardrail against PDF-library or allocator memory growth.
This commit is contained in:
2026-06-14 19:04:04 -04:00
parent b8bb31842a
commit de0b3ccdbc
2 changed files with 123 additions and 117 deletions
+1 -1
View File
@@ -15,4 +15,4 @@ COPY . .
EXPOSE 5000 EXPOSE 5000
# Command to run the application using Gunicorn (production ready) # Command to run the application using Gunicorn (production ready)
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"] CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--max-requests", "50", "--max-requests-jitter", "10", "app:app"]
+122 -116
View File
@@ -2,15 +2,15 @@ import sys
import gc import gc
import re import re
import json import json
import io
import logging import logging
import tempfile
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
import pdfplumber import pdfplumber
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter
from pypdf.constants import UserAccessPermissions from pypdf.constants import UserAccessPermissions
from pypdf.generic import NameObject, BooleanObject from pypdf.generic import NameObject, BooleanObject
from typing import TypedDict, Literal from typing import BinaryIO, TypedDict, Literal
from flask import Flask, request, send_file, abort from flask import Flask, request, send_file, abort
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
@@ -45,7 +45,7 @@ class ParsedPdfData(TypedDict):
highlighted_box: InterpretationType highlighted_box: InterpretationType
def process_pft_pdf(input_bytes: bytes) -> io.BytesIO: def process_pft_pdf(input_stream: BinaryIO) -> BinaryIO:
text_mapping: dict[str, InterpretationType] = { text_mapping: dict[str, InterpretationType] = {
"normal": "normal", "normal": "normal",
"pv disorders": "pvd", "pv disorders": "pvd",
@@ -70,8 +70,11 @@ def process_pft_pdf(input_bytes: bytes) -> io.BytesIO:
"highlighted_box": "normal" "highlighted_box": "normal"
} }
# Use a detached context for pdfplumber to read the file metadata input_stream.seek(0)
with pdfplumber.open(io.BytesIO(input_bytes)) as pdf:
# Use the upload stream directly so each request does not duplicate the PDF
# in memory before parsing.
with pdfplumber.open(input_stream) as pdf:
if len(pdf.pages) < 2: if len(pdf.pages) < 2:
logger.error( logger.error(
"Expected a 2-page PFT report, found %s page(s).", len(pdf.pages)) "Expected a 2-page PFT report, found %s page(s).", len(pdf.pages))
@@ -82,18 +85,17 @@ def process_pft_pdf(input_bytes: bytes) -> io.BytesIO:
page2 = pdf.pages[1] page2 = pdf.pages[1]
# --- 1. FULL TEXT METADATA EXTRACTION (Page 1) --- # --- 1. FULL TEXT METADATA EXTRACTION (Page 1) ---
full_text_p1 = page1.extract_text() full_text_p1 = page1.extract_text() or ""
if full_text_p1: lines = [line.strip()
lines = [line.strip() for line in full_text_p1.split("\n") if line.strip()]
for line in full_text_p1.split("\n") if line.strip()] for line in lines:
for line in lines: if "ID: #" in line:
if "ID: #" in line: record["patient_name"] = line.split("ID:")[0].strip()
record["patient_name"] = line.split("ID:")[0].strip() dob_match = re.search(r"\((\d{1,2}/\d{1,2}/\d{4})\)", line)
dob_match = re.search(r"\((\d{1,2}/\d{1,2}/\d{4})\)", line) if dob_match:
if dob_match: record["date_of_birth"] = dob_match.group(1)
record["date_of_birth"] = dob_match.group(1) break
break
dos_match = re.search( dos_match = re.search(
r"Test Date:\s*(\d{1,2}/\d{1,2}/\d{4})", full_text_p1) r"Test Date:\s*(\d{1,2}/\d{1,2}/\d{4})", full_text_p1)
@@ -106,11 +108,11 @@ def process_pft_pdf(input_bytes: bytes) -> io.BytesIO:
record["smoker"] = smoker_match.group(1).capitalize() record["smoker"] = smoker_match.group(1).capitalize()
# --- 2. INTERPRETATION DIAGRAM PROCESSING (Page 2) --- # --- 2. INTERPRETATION DIAGRAM PROCESSING (Page 2) ---
text_p2 = page2.extract_text() text_p2 = page2.extract_text() or ""
detected_box: InterpretationType = "normal" detected_box: InterpretationType = "normal"
box_found = False box_found = False
if text_p2 and "System Summary Interpretation" in text_p2: if "System Summary Interpretation" in text_p2:
summary_text = text_p2.split( summary_text = text_p2.split(
"System Summary Interpretation")[-1].strip().lower() "System Summary Interpretation")[-1].strip().lower()
for phrase, token in text_mapping.items(): for phrase, token in text_mapping.items():
@@ -154,99 +156,106 @@ def process_pft_pdf(input_bytes: bytes) -> io.BytesIO:
f"Template file '{TEMPLATE_NAME}' missing inside container workspace.") f"Template file '{TEMPLATE_NAME}' missing inside container workspace.")
writer = PdfWriter() writer = PdfWriter()
template_reader = PdfReader(TEMPLATE_NAME) template_reader = PdfReader(TEMPLATE_NAME)
writer.append(template_reader) raw_results_reader: PdfReader | None = None
patient_data = {"patient_name": "",
"date_of_service": "", "date_of_birth": ""}
check_boxes = {
"results_cw_neuro_disorder": "/Off", "results_ild_pn": "/Off", "results_asthma_cb": "/Off",
"results_emphysema": "/Off", "refer_pulmo": "/Off", "refer_smoking": "/Off",
"results_pvd": "/Off", "results_normal": "/Off"
}
formatted_dos = datetime.strptime(record.get(
"date_of_service"), "%m/%d/%Y").strftime("%m/%d/%Y") if record.get("date_of_service") else ""
formatted_dob = datetime.strptime(record.get(
"date_of_birth"), "%m/%d/%Y").strftime("%m/%d/%Y") if record.get("date_of_birth") else ""
patient_data |= {
"patient_name": record.get("patient_name").title() if record.get("patient_name") else "",
"date_of_service": formatted_dos,
"date_of_birth": formatted_dob,
}
check_boxes |= {
"refer_smoking": "/Yes" if record.get("smoker") == "Yes" else "/Off"}
match record["highlighted_box"]:
case "normal":
check_boxes |= {"results_normal": "/Yes"}
case "pvd":
check_boxes |= {"results_pvd": "/Yes", "refer_pulmo": "/Yes"}
case "cw_neuro_disorder":
check_boxes |= {
"results_cw_neuro_disorder": "/Yes", "refer_pulmo": "/Yes"}
case "ild_pn":
check_boxes |= {"results_ild_pn": "/Yes", "refer_pulmo": "/Yes"}
case "asthma_cb":
check_boxes |= {"results_asthma_cb": "/Yes", "refer_pulmo": "/Yes"}
case "emphysema":
check_boxes |= {"results_emphysema": "/Yes", "refer_pulmo": "/Yes"}
form_data = patient_data | check_boxes
writer.get_fields()
writer.update_page_form_field_values(
writer.pages[0],
form_data,
auto_regenerate=False,
flatten=True
)
for page in writer.pages:
if "/Annots" in page:
for annot in page["/Annots"]:
annot_obj = annot.get_object()
if annot_obj.get("/t") in check_boxes:
field_name = annot_obj["/T"]
target_value = check_boxes[field_name]
annot_obj.update({
NameObject("/V"): NameObject(target_value),
NameObject("/AS"): NameObject(target_value)
})
try: try:
writer.set_need_appearances_writer() writer.append(template_reader)
except AttributeError:
if "/AcroForm" in writer._root_object:
writer._root_object["/AcroForm"].update({
NameObject("/NeedAppearances"): BooleanObject(True)
})
# Strip widget annotations patient_data = {"patient_name": "",
writer.remove_annotations(subtypes="/Widget") "date_of_service": "", "date_of_birth": ""}
check_boxes = {
"results_cw_neuro_disorder": "/Off", "results_ild_pn": "/Off", "results_asthma_cb": "/Off",
"results_emphysema": "/Off", "refer_pulmo": "/Off", "refer_smoking": "/Off",
"results_pvd": "/Off", "results_normal": "/Off"
}
# Append raw input pages directly onto the end of our current working context formatted_dos = datetime.strptime(record.get(
raw_results_reader = PdfReader(io.BytesIO(input_bytes)) "date_of_service"), "%m/%d/%Y").strftime("%m/%d/%Y") if record.get("date_of_service") else ""
writer.append(raw_results_reader) formatted_dob = datetime.strptime(record.get(
"date_of_birth"), "%m/%d/%Y").strftime("%m/%d/%Y") if record.get("date_of_birth") else ""
# Secure document permissions patient_data |= {
permissions = UserAccessPermissions.PRINT | UserAccessPermissions.EXTRACT "patient_name": record.get("patient_name").title() if record.get("patient_name") else "",
owner_pw = "heather" "date_of_service": formatted_dos,
"date_of_birth": formatted_dob,
}
writer.encrypt( check_boxes |= {
user_password="", "refer_smoking": "/Yes" if record.get("smoker") == "Yes" else "/Off"}
owner_password=owner_pw,
permissions_flag=permissions
)
# Output final joined stream directly to Flask match record["highlighted_box"]:
output_stream = io.BytesIO() case "normal":
writer.write(output_stream) check_boxes |= {"results_normal": "/Yes"}
output_stream.seek(0) case "pvd":
check_boxes |= {"results_pvd": "/Yes", "refer_pulmo": "/Yes"}
case "cw_neuro_disorder":
check_boxes |= {
"results_cw_neuro_disorder": "/Yes", "refer_pulmo": "/Yes"}
case "ild_pn":
check_boxes |= {"results_ild_pn": "/Yes", "refer_pulmo": "/Yes"}
case "asthma_cb":
check_boxes |= {"results_asthma_cb": "/Yes", "refer_pulmo": "/Yes"}
case "emphysema":
check_boxes |= {"results_emphysema": "/Yes", "refer_pulmo": "/Yes"}
form_data = patient_data | check_boxes
writer.get_fields()
writer.update_page_form_field_values(
writer.pages[0],
form_data,
auto_regenerate=False,
flatten=True
)
for page in writer.pages:
if "/Annots" in page:
for annot in page["/Annots"]:
annot_obj = annot.get_object()
if annot_obj.get("/t") in check_boxes:
field_name = annot_obj["/T"]
target_value = check_boxes[field_name]
annot_obj.update({
NameObject("/V"): NameObject(target_value),
NameObject("/AS"): NameObject(target_value)
})
try:
writer.set_need_appearances_writer()
except AttributeError:
if "/AcroForm" in writer._root_object:
writer._root_object["/AcroForm"].update({
NameObject("/NeedAppearances"): BooleanObject(True)
})
# Strip widget annotations
writer.remove_annotations(subtypes="/Widget")
# Append raw input pages directly onto the end of our current working context
input_stream.seek(0)
raw_results_reader = PdfReader(input_stream)
writer.append(raw_results_reader)
# Secure document permissions
permissions = UserAccessPermissions.PRINT | UserAccessPermissions.EXTRACT
owner_pw = "heather"
writer.encrypt(
user_password="",
owner_password=owner_pw,
permissions_flag=permissions
)
# Keep small responses in memory, but spill larger PDFs to a temp file.
output_stream = tempfile.SpooledTemporaryFile(max_size=8 * 1024 * 1024, mode="w+b")
writer.write(output_stream)
output_stream.seek(0)
finally:
template_reader.close()
if raw_results_reader is not None:
raw_results_reader.close()
logger.info("PDTR successfully prepended to PFT result PDF.") logger.info("PDTR successfully prepended to PFT result PDF.")
return output_stream return output_stream
@@ -268,20 +277,24 @@ def upload_file():
logger.info("Received PDF: %s", original_filename) logger.info("Received PDF: %s", original_filename)
try: try:
# Read input file directly into memory bytes # Execute logic sequentially using the upload stream directly.
raw_input_bytes = file.read() processed_file_stream = process_pft_pdf(file.stream)
# Execute logic sequentially
processed_file_stream = process_pft_pdf(raw_input_bytes)
logger.info("Returning processed PDF file: %s", original_filename) logger.info("Returning processed PDF file: %s", original_filename)
return send_file( response = send_file(
processed_file_stream, processed_file_stream,
mimetype='application/pdf', mimetype='application/pdf',
as_attachment=True, as_attachment=True,
download_name=original_filename download_name=original_filename
) )
@response.call_on_close
def close_processed_file_stream() -> None:
processed_file_stream.close()
gc.collect()
return response
except Exception as e: except Exception as e:
logger.exception("Error during execution processing pipeline") logger.exception("Error during execution processing pipeline")
return abort(500, f"Error processing file: {str(e)}") return abort(500, f"Error processing file: {str(e)}")
@@ -323,12 +336,5 @@ def index():
""" """
@app.teardown_request
def spi_clear_memory(exception=None):
"""Runs automatically after every single request."""
# Force collection of unreferenced objects
gc.collect()
if __name__ == "__main__": if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000) app.run(host='0.0.0.0', port=5000)