Skip to content

Commit ac35636

Browse files
authored
Merge pull request #13 from podaac/PODAAC-7294-support-large-file-uploads
PODAAC-7294 - Support large file uploads
2 parents e1c0bde + e3c0404 commit ac35636

5 files changed

Lines changed: 147 additions & 51 deletions

File tree

mcc/checker.wsgi

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ sys.path.append(os.path.dirname(__file__))
1111

1212

1313
def application(environ, start_response):
14-
os.environ['ApiMaxFileSize'] = environ.get('ApiMaxFileSize', '10737418240')
15-
os.environ['UiMaxFileSize'] = environ.get('UiMaxFileSize', '4295000000')
14+
# Increased API file size limit to 5GB (5 * 1024^3 = 5,368,709,120 bytes)
15+
os.environ['ApiMaxFileSize'] = environ.get('ApiMaxFileSize', '5368709120')
16+
# Increased UI file size limit to 5GB (5 * 1024^3 = 5,368,709,120 bytes)
17+
os.environ['UiMaxFileSize'] = environ.get('UiMaxFileSize', '5368709120')
1618
os.environ['HomepageURL'] = environ.get('HomepageURL', '"https://mcc.podaac.earthdatacloud.nasa.gov"')
17-
os.environ['TempFileLocation'] = environ.get('TempFileLocation', tempfile.gettempdir())
19+
# Use /tmp for temporary file storage to ensure enough space for 4GB+ files
20+
os.environ['TempFileLocation'] = environ.get('TempFileLocation', '/tmp')
1821
os.environ['Venue'] = environ.get('Venue', 'OPS')
1922
os.environ['CF_STANDARD_NAME_TABLE'] = environ.get('CF_STANDARD_NAME_TABLE', '')
2023

mcc/web/file_utils.py

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,33 @@
2323
logger = logging.getLogger(__name__)
2424

2525

26-
def hash_file(infile, hasher=None, blocksize=65536):
26+
def hash_file(infile, hasher=None, blocksize=8388608): # 8MB buffer (8 * 1024 * 1024)
2727
"""
28-
Incrementally generate file hashes (suitable for large files).
28+
Incrementally generate file hashes (suitable for very large files).
29+
Uses a large buffer size (8MB) for better performance with multi-GB files.
2930
3031
@param infile a python file-like object
3132
@param hasher a hasher function from hashlib library (e.g. md5, sha256, ...)
32-
@param blocksize an integer of bytes to read into the hash at a time
33+
@param blocksize an integer of bytes to read into the hash at a time (default: 8MB)
3334
@return a hexdigest of the hash based on the hasher
3435
"""
3536
if hasher is None:
3637
hasher = md5()
3738

39+
# Save current file position
40+
current_pos = infile.tell()
41+
42+
# Go to beginning of file
43+
infile.seek(0)
44+
45+
# Read and hash in chunks
3846
buf = infile.read(blocksize)
39-
4047
while len(buf) > 0:
4148
hasher.update(buf)
4249
buf = infile.read(blocksize)
4350

44-
# Reset file buffer back to starting position
45-
infile.seek(0)
51+
# Reset file buffer back to original position
52+
infile.seek(current_pos)
4653

4754
return hasher.hexdigest()
4855

@@ -67,31 +74,19 @@ def format_byte_size(n_bytes):
6774
def decompress_file(infile, upload_filename):
6875
"""
6976
Opens gzip and bz2 files and returns the uncompressed data.
70-
Uncompresses in pieces andand cuts off when limit is reached in order to
71-
avoid 'zip bombs'.
72-
73-
Solution suggested by Mark Adler:
74-
http://stackoverflow.com/questions/13622706/how-to-protect-myself-from-a-gzip-or-bzip2-bomb
75-
76-
The gist is that we decompress the file in chunks, counting the uncompressed
77-
size of each and adding it to the total. If the filesize is OK, then
78-
decompress the file and return data. This seems a little redundant, but
79-
uncompressing the actual file in chunks and storing the data creates
80-
crashing issues in the Docker container.
81-
82-
TODO: Bz2 doesn't have the same ability as zlib to read in chunks, so a bomb is
83-
just going to cause a memory error. So, given the extra time it takes to
84-
decompress a bz2, I'm just assembling the chunks on the fly instead of
85-
unzipping a second time. Investigate a workaround for this at some point.
77+
Optimized for handling large files (4GB+) efficiently.
78+
Uncompresses in pieces and cuts off when limit is reached to avoid 'zip bombs'.
8679
8780
@param infile file object containing the data to decompress
8881
@param upload_filename name of the file to decompress
8982
@return a temporary file object containing the decompressed data
9083
"""
9184
app.logger.info("Decompressing file %s", upload_filename)
9285

93-
decompressed_file = tempfile.NamedTemporaryFile()
94-
extension = os.path.splitext(upload_filename)[-1]
86+
# Use the configured temporary directory for large file processing
87+
temp_dir = app.config.get('TEMP_FILE_DIR', tempfile.gettempdir())
88+
decompressed_file = tempfile.NamedTemporaryFile(dir=temp_dir)
89+
extension = os.path.splitext(upload_filename)[-1].lower()
9590

9691
# Determine the correct open function based on the type of decompression
9792
if extension == ".gz":
@@ -101,67 +96,107 @@ def decompress_file(infile, upload_filename):
10196
else:
10297
raise ValueError(f"Unknown file extension ({extension}) for decompression")
10398

99+
# Use a larger buffer size for better performance with large files
100+
buffer_size = 8 * 1024 * 1024 # 8MB buffer
101+
104102
try:
105103
data_length = 0
106104
reader = open_fn(infile)
107105

106+
# Stream decompression with a larger buffer
108107
while data_length < app.config['MAX_CONTENT_LENGTH']:
109-
buf = reader.read(1024)
108+
buf = reader.read(buffer_size)
110109

111110
if len(buf) == 0:
112111
break
113112

114113
data_length += len(buf)
115114
decompressed_file.write(buf)
116115
else:
116+
# If we exit the loop without breaking, the file is too large
117117
decompressed_file.close()
118118
return abort(
119119
400, f"The decompressed file size is too large. "
120120
f"Max decompressed file size is: {format_byte_size(app.config['MAX_CONTENT_LENGTH'])}. "
121121
f"Filename: {upload_filename}"
122122
)
123+
124+
# Close the reader to free resources
125+
reader.close()
126+
123127
except (BadGzipFile, OSError, ValueError, TypeError, IOError, EOFError) as err:
124128
decompressed_file.close()
125129
raise ValueError(
126130
f'Failed to decompress {extension} file {upload_filename}, reason: {str(err)}.'
127131
)
128132
except MemoryError:
129-
# Noticed that some bzip bombs cause memory errors. There doesn't
130-
# seem to be a ton of great ways around this.
133+
# Handle memory errors from bzip bombs
131134
decompressed_file.close()
132135
return abort(
133-
400, f"The decompressed file size is too large. "
136+
400, f"The decompressed file size is too large or caused a memory error. "
134137
f"Max decompressed file size is: {format_byte_size(app.config['MAX_CONTENT_LENGTH'])}. "
135138
f"Filename: {upload_filename}"
136139
)
137140

138141
# Roll file pointer back to beginning of buffer now that decompression is complete
139142
decompressed_file.seek(0)
143+
app.logger.info(f"Successfully decompressed {upload_filename} to size {format_byte_size(data_length)}")
140144

141145
return decompressed_file
142146

143147

144148
def get_dataset_from_file(uploaded_file):
145149
"""
146150
Derives a netcdf4.Dataset object from the provided file upload dictionary.
151+
Optimized for handling large files (4GB+) efficiently.
147152
148-
@param uploaded_file open file handle to the data to convert.
153+
@param uploaded_file open file handle or path to the data to convert.
149154
"""
150155
app.logger.info("Attempting to get dataset from uploaded file %s", uploaded_file)
151-
152-
datafile_name = uploaded_file.filename
156+
157+
# Handle both file paths and file objects
158+
if isinstance(uploaded_file, str):
159+
# It's a file path
160+
datafile_name = os.path.basename(uploaded_file)
161+
is_path = True
162+
else:
163+
# It's a file object
164+
datafile_name = uploaded_file.filename
165+
is_path = False
166+
153167
check_valid_filename(datafile_name)
154-
155-
# uploaded_file is an instance of werkzeug.FileStorage, which only allows us
156-
# to read the file contents but not reference a location on disk.
157-
# Copy the uploaded file to a named temporary file, so we can provide a disk
158-
# location when initializing the netCDF.Dataset object below.
159-
160-
# Since the CF suite checks the Dataset's filename for compliance,
161-
# we need to make sure the original is incorporated into the name of the
162-
# temporary file used to initialize the Dataset object below.
163-
datafile = tempfile.NamedTemporaryFile(suffix=f"_{datafile_name}")
164-
datafile.write(uploaded_file.read())
168+
169+
# For large files, we want to avoid loading the entire file into memory
170+
# Instead, we'll use a temporary file and stream the data in chunks
171+
172+
# Use the configured temporary directory for large file processing
173+
temp_dir = app.config.get('TEMP_FILE_DIR', tempfile.gettempdir())
174+
175+
# Create a named temporary file in the specified directory
176+
datafile = tempfile.NamedTemporaryFile(dir=temp_dir, suffix=f"_{datafile_name}")
177+
178+
# Stream the file in chunks to avoid memory issues
179+
if is_path:
180+
# If it's a path, open the file and copy it
181+
with open(uploaded_file, 'rb') as src_file:
182+
# Use a larger buffer size (8MB) for faster copying of large files
183+
buffer_size = 8 * 1024 * 1024 # 8MB buffer
184+
while True:
185+
buffer = src_file.read(buffer_size)
186+
if not buffer:
187+
break
188+
datafile.write(buffer)
189+
else:
190+
# If it's a file object, stream from it
191+
# Use a larger buffer size (8MB) for faster copying of large files
192+
buffer_size = 8 * 1024 * 1024 # 8MB buffer
193+
while True:
194+
buffer = uploaded_file.read(buffer_size)
195+
if not buffer:
196+
break
197+
datafile.write(buffer)
198+
199+
# Reset file pointer to beginning
165200
datafile.seek(0)
166201

167202
# Calculate hash and file size now, prior to any potential file decompression

mcc/web/form_utils.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def get_tests(form_dict, checker_map):
4747
def parse_post_arguments(form_dict, files, checker_map):
4848
"""
4949
Parse a POST request from either an HTML page form or a cURL-like request.
50+
Optimized for handling large files (4GB+) efficiently.
5051
Aborts response if no tests or no files.
5152
5253
@param form_dict ImmutableMultiDict from flask or a regular
@@ -57,23 +58,37 @@ def parse_post_arguments(form_dict, files, checker_map):
5758
@param files a dict with a flask file-like object
5859
@param checker_map a dict of checker short names to initialized checkers
5960
@return a dict with 'file', 'checkers', 'response' or abort()
60-
# TODO determine if this is an additional place to verify upload size constraint
6161
"""
62+
from flask import current_app
63+
app = current_app
64+
6265
ret = {}
6366

67+
# Get the selected checkers
6468
checkers = get_tests(form_dict, checker_map)
6569

6670
if not checkers:
6771
return abort(
6872
400, "You need to choose at least one metadata convention to test your file against."
6973
)
7074

71-
if 'file-upload' not in files:
75+
# Check for file upload
76+
# Try multiple possible field names for file uploads
77+
uploaded_file = None
78+
for field in ['file-upload', 'file', 'upload', 'fileUpload']:
79+
if field in files and files[field]:
80+
uploaded_file = files[field]
81+
app.logger.info(f"Found file in field: {field}")
82+
break
83+
84+
if not uploaded_file:
7285
return abort(400, "Your request was empty. Please make sure you've specified a file.")
73-
elif not files['file-upload']:
74-
return abort(400, "There was a problem uploading your file. Please try again.")
75-
76-
ret['file'] = files['file-upload']
86+
87+
# Log file information
88+
app.logger.info(f"File received: {uploaded_file.filename}")
89+
90+
# Return the file object directly - we'll handle streaming in get_dataset_from_file
91+
ret['file'] = uploaded_file
7792
ret['checkers'] = checkers
7893
ret['response'] = form_dict.get('response', 'html').lower()
7994

mcc/web/server.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
88
"""
99

10+
import os
1011
import time
12+
import logging
1113
from os import environ
1214
from os.path import join
1315

@@ -21,6 +23,11 @@
2123
from .form_utils import parse_post_arguments
2224
from .json_utils import CustomJSONEncoder
2325

26+
# Configure logging
27+
logging.basicConfig(level=logging.INFO,
28+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
29+
logger = logging.getLogger(__name__)
30+
2431
app = Flask(__name__)
2532

2633
# Style options for whitespace in templated HTML code.
@@ -42,6 +49,20 @@
4249
# Venue that MCC is deployed to (SIT, UAT, or OPS)
4350
app.config['Venue'] = str(environ['Venue'])
4451

52+
# Temporary directory for file processing
53+
app.config['TEMP_FILE_DIR'] = environ.get('TempFileLocation', '/tmp')
54+
55+
# Ensure the temporary directory exists and is writable
56+
if not os.path.exists(app.config['TEMP_FILE_DIR']):
57+
try:
58+
os.makedirs(app.config['TEMP_FILE_DIR'], exist_ok=True)
59+
logger.info(f"Created temporary directory: {app.config['TEMP_FILE_DIR']}")
60+
except Exception as e:
61+
logger.warning(f"Error creating temporary directory {app.config['TEMP_FILE_DIR']}: {str(e)}")
62+
63+
if not os.access(app.config['TEMP_FILE_DIR'], os.W_OK):
64+
logger.warning(f"Temporary directory {app.config['TEMP_FILE_DIR']} is not writable")
65+
4566
# Mapping of checker short names to CheckSuite implementations.
4667
# This could be easily kept up-to-date by inspection of a module, but
4768
# prefer the explicit writing of current checkers.

mcc/web/templates/about_api.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,31 @@ <h3>POST Examples</h3>
191191
<p>
192192
<em>Note that you need to append an '@' to the beginning of the filename in the CURL request when using the file-upload option.</em>
193193
</p>
194+
<h4>Basic Examples</h4>
194195
<pre>curl -L -F ACDD=on -F ACDD-version=1.3 -F file-upload=@/home/user/granule.nc -F response=json {{ homepage_url }}/check
195196
curl -L -F CF=on -F CF-version=1.7 -F file-upload=@/home/user/granule.nc -F response=html {{ homepage_url }}/check
196197
curl -L -F GDS2=on -F GDS2-parameter=L4 -F file-upload=@/home/user/granule.nc -F response=pdf {{ homepage_url }}/check</pre>
198+
199+
<h4>With Progress Reporting and Extended Timeouts</h4>
200+
<p>
201+
<em>For large files, you may want to show progress and set longer timeouts:</em>
202+
</p>
203+
<pre>curl -L --progress-bar --connect-timeout 300 --max-time 3600 --keepalive-time 3600 \
204+
-F ACDD=on -F ACDD-version=1.3 -F file-upload=@/home/user/granule.nc -F response=json {{ homepage_url }}/check</pre>
205+
206+
<h4>With Detailed Progress</h4>
207+
<pre>curl -L -v --progress-meter --connect-timeout 300 --max-time 3600 --keepalive-time 3600 \
208+
-F CF=on -F CF-version=1.7 -F file-upload=@/home/user/granule.nc -F response=html {{ homepage_url }}/check</pre>
209+
210+
<h4>Options Explained</h4>
211+
<ul>
212+
<li><code>--progress-bar</code>: Display a simple progress bar</li>
213+
<li><code>--progress-meter</code>: Display detailed progress information</li>
214+
<li><code>-v</code>: Verbose output showing request and response headers</li>
215+
<li><code>--connect-timeout 300</code>: Wait up to 300 seconds when connecting</li>
216+
<li><code>--max-time 3600</code>: Allow the whole operation to take up to 3600 seconds (1 hour)</li>
217+
<li><code>--keepalive-time 3600</code>: Keep the connection alive for 3600 seconds</li>
218+
</ul>
197219
</div>
198220
</div>
199221
</div>

0 commit comments

Comments
 (0)