Skip to content

Commit 8d028f1

Browse files
author
Scott Collins
committed
wip failure simulation
1 parent 8f8947e commit 8d028f1

6 files changed

Lines changed: 196 additions & 21 deletions

File tree

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ install_requires =
3434
more-itertools>=9.0,<10.8
3535
joblib>=1.3.1,<1.6.0
3636
requests~=2.23
37+
requests-mock~=1.12.1
3738
types-requests~=2.23
3839
PyYAML~=6.0
3940
setuptools~=75.8.1

src/pds/ingress/client/pds_ingress_client.py

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@
2121
import backoff
2222
import pds.ingress.util.log_util as log_util
2323
import requests
24+
import requests_mock
2425
from joblib import delayed
2526
from joblib import Parallel
2627
from more_itertools import chunked as batched
2728
from pds.ingress import __version__
2829
from pds.ingress.util.auth_util import AuthUtil
30+
from pds.ingress.util.backoff_util import simulate_batch_request_failure
31+
from pds.ingress.util.backoff_util import simulate_ingress_failure
2932
from pds.ingress.util.config_util import ConfigUtil
3033
from pds.ingress.util.hash_util import md5_for_path
3134
from pds.ingress.util.log_util import get_log_level
@@ -193,7 +196,8 @@ def _process_batch(batch_index, request_batch, node_id, force_overwrite, api_gat
193196
except Exception as err:
194197
# If here, the HTTP request error was unrecoverable by a backoff/retry
195198
trimmed_path = ingress_response.get("trimmed_path")
196-
update_summary_table(SUMMARY_TABLE, "failed", trimmed_path)
199+
ingress_path = ingress_response.get("ingress_path")
200+
update_summary_table(SUMMARY_TABLE, "failed", ingress_path)
197201

198202
logger.error("Batch %d : Ingress failed for %s, Reason:\n%s", batch_index, trimmed_path, str(err))
199203

@@ -352,10 +356,11 @@ def _prepare_batch_for_ingress(ingress_path_batch, prefix, batch_index, batch_pb
352356
@backoff.on_exception(
353357
backoff.expo,
354358
Exception,
355-
max_time=120,
359+
max_time=10, # TODO: for testing only, revert to 120
356360
logger="request_batch_for_ingress",
357361
)
358-
def request_batch_for_ingress(request_batch, batch_index, node_id, force_overwrite, api_gateway_config):
362+
@requests_mock.Mocker(kw="mock_requests", real_http=True)
363+
def request_batch_for_ingress(request_batch, batch_index, node_id, force_overwrite, api_gateway_config, **kwargs):
359364
"""
360365
Submits a batch of ingress requests to the PDS Ingress App API.
361366
@@ -409,9 +414,12 @@ def request_batch_for_ingress(request_batch, batch_index, node_id, force_overwri
409414
"x-amz-docs-region": api_gateway_region,
410415
}
411416

412-
response = requests.post(
413-
api_gateway_url, params=params, data=json.dumps(request_batch), headers=headers, timeout=600
414-
)
417+
# Simulate a random failure for the batch request if configured to do so
418+
with simulate_batch_request_failure(kwargs["mock_requests"], api_gateway_url.split("?")[0]):
419+
response = requests.post(
420+
api_gateway_url, params=params, data=json.dumps(request_batch), headers=headers, timeout=600
421+
)
422+
415423
elapsed_time = time.time() - start_time
416424

417425
# Ingress request successful
@@ -428,10 +436,11 @@ def request_batch_for_ingress(request_batch, batch_index, node_id, force_overwri
428436
@backoff.on_exception(
429437
backoff.expo,
430438
Exception,
431-
max_time=120,
439+
max_time=10, # TODO: for testing only, revert to 120
432440
logger="ingress_file_to_s3",
433441
)
434-
def ingress_file_to_s3(ingress_response, batch_index, batch_pbar):
442+
@requests_mock.Mocker(kw="mock_requests", real_http=True)
443+
def ingress_file_to_s3(ingress_response, batch_index, batch_pbar, **kwargs):
435444
"""
436445
Copies the local file path using the pre-signed S3 URL returned from the
437446
Ingress Lambda App.
@@ -487,13 +496,15 @@ def ingress_file_to_s3(ingress_response, batch_index, batch_pbar):
487496
batch_pbar, total=os.stat(ingress_path).st_size, filename=os.path.basename(ingress_path)
488497
)
489498

490-
with open(ingress_path, "rb") as infile:
491-
# Wrap file I/O with our upload bar to automatically track file upload progress
492-
wrapped_file = CallbackIOWrapper(upload_pbar.update, infile, "read")
499+
# Simulate a random failure for the S3 ingress request if configured to do so
500+
with simulate_ingress_failure(kwargs["mock_requests"], s3_ingress_url.split("?")[0]):
501+
with open(ingress_path, "rb") as infile:
502+
# Wrap file I/O with our upload bar to automatically track file upload progress
503+
wrapped_file = CallbackIOWrapper(upload_pbar.update, infile, "read")
493504

494-
# Only send the file data if the file is non-empty
495-
response = requests.put(s3_ingress_url, data=wrapped_file if file_length > 0 else b"", headers=headers)
496-
response.raise_for_status()
505+
# Only send the file data if the file is non-empty
506+
response = requests.put(s3_ingress_url, data=wrapped_file if file_length > 0 else b"", headers=headers)
507+
response.raise_for_status()
497508

498509
logger.info("Batch %d : %s Ingest complete", batch_index, trimmed_path)
499510
update_summary_table(SUMMARY_TABLE, "uploaded", ingress_path)
@@ -517,10 +528,11 @@ def ingress_file_to_s3(ingress_response, batch_index, batch_pbar):
517528
@backoff.on_exception(
518529
backoff.expo,
519530
Exception,
520-
max_time=120,
531+
max_time=10, # TODO: for testing only, revert to 120
521532
logger="ingress_multipart_file_to_s3",
522533
)
523-
def ingress_multipart_file_to_s3(ingress_response, batch_index, batch_pbar):
534+
@requests_mock.Mocker(kw="mock_requests", real_http=True)
535+
def ingress_multipart_file_to_s3(ingress_response, batch_index, batch_pbar, **kwargs):
524536
"""
525537
Performs an ingress request for a file that is too large to be uploaded
526538
in a single request. The file is instead uploaded in multiple parts using
@@ -582,9 +594,11 @@ def ingress_multipart_file_to_s3(ingress_response, batch_index, batch_pbar):
582594
upload_pbar, f"{os.path.basename(ingress_path)} (Part {part_number}/{len(s3_ingress_urls)})"
583595
)
584596

585-
# Submit a single chunk to AWS
586-
response = requests.put(s3_ingress_url, data=next(chunk_iterator))
587-
response.raise_for_status()
597+
# Simulate a random failure for the S3 ingress request if configured to do so
598+
with simulate_ingress_failure(kwargs["mock_requests"], s3_ingress_url.split("?")[0]):
599+
# Submit a single chunk to AWS
600+
response = requests.put(s3_ingress_url, data=next(chunk_iterator))
601+
response.raise_for_status()
588602

589603
completed_parts.append({"ETag": response.headers["ETag"], "PartNumber": part_number})
590604
except Exception as err:
@@ -848,16 +862,22 @@ def main(args):
848862
try:
849863
init_batch_progress_bars(args.num_threads)
850864
perform_ingress(request_batchs, node_id, args.force_overwrite, config["API_GATEWAY"])
865+
finally:
866+
close_batch_progress_bars()
851867

852-
logger.info("All batches processed")
868+
logger.info("All batches processed")
853869

870+
try:
854871
if len(SUMMARY_TABLE["failed"]) > 0:
872+
logger.info("----------------------------------------")
855873
logger.info("Reattempting ingress for failed files...")
874+
856875
failed_ingresses = SUMMARY_TABLE["failed"]
857876
batched_failed_ingresses = list(batched(failed_ingresses, batch_size))
858877
failed_request_batchs = prepare_batches(batched_failed_ingresses, args.prefix)
878+
879+
init_batch_progress_bars(args.num_threads)
859880
perform_ingress(failed_request_batchs, node_id, args.force_overwrite, config["API_GATEWAY"])
860-
logger.info("Reattempted ingress complete")
861881
finally:
862882
close_batch_progress_bars()
863883

src/pds/ingress/conf.default.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ console_format = "%(message)s"
1919
log_group_name = "/pds/nucleus/dum/client-log-group"
2020
log_file_path =
2121
batch_size = 250
22+
23+
[DEBUG]
24+
simulate_batch_request_failures = false
25+
batch_request_failure_rate = 0
26+
simulate_ingress_failures = false
27+
ingress_failure_rate = 0

src/pds/ingress/util/backoff_util.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
77
automatic backoff/retry of HTTP requests.
88
99
"""
10+
import random
11+
12+
from contextlib import contextmanager
13+
from distutils.util import strtobool
1014
from http import HTTPStatus
1115

16+
from pds.ingress.util.config_util import ConfigUtil
17+
1218
# When leveraging this module with the Lambda service functions, the requests
1319
# module will not be available within the Python runtime.
1420
# It should not be needed by those Lambda's, so use MagicMock to bypass any
@@ -60,3 +66,138 @@ def fatal_code(err: requests.exceptions.RequestException) -> bool:
6066
else:
6167
# No response to interrogate, so default to no retry
6268
return True
69+
70+
71+
def check_failure_chance(percentage: int) -> bool:
72+
"""
73+
Checks if a simulated failure event should occur based on a given percentage
74+
chance.
75+
76+
Parameters
77+
----------
78+
percentage : int
79+
The desired percentage chance (e.g., 70 for 70%).
80+
81+
Returns
82+
-------
83+
bool: True if the failure should occur, False otherwise.
84+
85+
Raises
86+
------
87+
ValueError: If the percentage is not between 0 and 100.
88+
89+
"""
90+
if not (0 <= percentage <= 100):
91+
raise ValueError("Percentage must be between 0 and 100.")
92+
93+
# Generate a random float between 0.0 and 1.0
94+
random_number = random.random()
95+
96+
# Convert the percentage to a decimal for comparison
97+
chance_threshold = percentage / 100.0
98+
99+
return random_number < chance_threshold
100+
101+
102+
@contextmanager
103+
def _simulate_requests_failure(mock_requests, url, http_method, enable_key, failure_rate_key):
104+
"""
105+
Simulates a random failure for S3 ingress by registering the provided
106+
ingress URL with the requests mocker to raise an HTTPError exception.
107+
108+
Whether the failure is simulated is determined by the `simulate_ingress_failures`
109+
configuration option and the `ingress_failure_rate` percentage chance within
110+
the optional DEBUG section of the INI config. If this section is not present,
111+
this function should always default to not simulating a failure.
112+
113+
If the failure is not simulated, the mock_requests instance is reset to
114+
ensure no previous failures are registered.
115+
116+
Parameters
117+
----------
118+
mock_requests : requests_mock.Mocker
119+
Mocked requests instance to register the simulated failure.
120+
url : str
121+
The URL to which the simulated failure will be applied.
122+
http_method : str
123+
HTTP method to register the failure for (e.g., 'POST', 'PUT').
124+
enable_key : str
125+
Name of the INI key to check if failure simulation is enabled.
126+
failure_rate_key : str
127+
Name of the INI key that specifies the percentage chance of failure.
128+
129+
"""
130+
config = ConfigUtil.get_config()
131+
132+
try:
133+
# Check if simulated failures are enabled, and if so, if we should simulate
134+
# a failure via mock_requests based on the configured failure chance
135+
if bool(strtobool(config.get("DEBUG", enable_key, fallback="false"))):
136+
if check_failure_chance(int(config.get("DEBUG", failure_rate_key, fallback="0"))):
137+
mock_requests.register_uri(http_method, url, exc=requests.exceptions.HTTPError)
138+
139+
yield mock_requests
140+
finally:
141+
# Remove any previously registered URL(s)
142+
mock_requests.reset()
143+
144+
145+
@contextmanager
146+
def simulate_batch_request_failure(mock_requests, api_gateway_url):
147+
"""
148+
Simulates a random failure for an ingress batch request by registering the
149+
provided ingress URL with the requests mocker to raise an HTTPError exception.
150+
151+
Parameters
152+
----------
153+
mock_requests : requests_mock.Mocker
154+
Mocked requests instance to register the simulated failure.
155+
api_gateway_url : str
156+
The API Gateway URL to which the simulated failure will be applied.
157+
158+
"""
159+
#_simulate_requests_failure(
160+
# mock_requests, api_gateway_url, "POST", "simulate_batch_request_failures", "batch_request_failure_rate"
161+
#)
162+
config = ConfigUtil.get_config()
163+
164+
try:
165+
# Check if simulated failures are enabled, and if so, if we should simulate
166+
# a failure via mock_requests based on the configured failure chance
167+
if bool(strtobool(config.get("DEBUG", "simulate_batch_request_failures", fallback="false"))):
168+
if check_failure_chance(int(config.get("DEBUG", "batch_request_failure_rate", fallback="0"))):
169+
mock_requests.register_uri("POST", api_gateway_url, exc=requests.exceptions.HTTPError)
170+
171+
yield mock_requests
172+
finally:
173+
# Remove any previously registered URL(s)
174+
mock_requests.reset()
175+
176+
@contextmanager
177+
def simulate_ingress_failure(mock_requests, s3_ingress_url):
178+
"""
179+
Simulates a random failure for S3 ingress by registering the provided
180+
ingress URL with the requests mocker to raise an HTTPError exception.
181+
182+
Parameters
183+
----------
184+
mock_requests : requests_mock.Mocker
185+
Mocked requests instance to register the simulated failure.
186+
s3_ingress_url : str
187+
The S3 ingress URL to which the simulated failure will be applied.
188+
189+
"""
190+
#_simulate_requests_failure(mock_requests, s3_ingress_url, "PUT", "simulate_ingress_failures", "ingress_failure_rate")
191+
config = ConfigUtil.get_config()
192+
193+
try:
194+
# Check if simulated failures are enabled, and if so, if we should simulate
195+
# a failure via mock_requests based on the configured failure chance
196+
if bool(strtobool(config.get("DEBUG", "simulate_ingress_failures", fallback="false"))):
197+
if check_failure_chance(int(config.get("DEBUG", "ingress_failure_rate", fallback="0"))):
198+
mock_requests.register_uri("PUT", s3_ingress_url, exc=requests.exceptions.HTTPError)
199+
200+
yield mock_requests
201+
finally:
202+
# Remove any previously registered URL(s)
203+
mock_requests.reset()

src/pds/ingress/util/progress_util.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,12 @@ def update_upload_pbar_filename(upload_pbar, filename):
279279

280280
def close_batch_progress_bars():
281281
"""Closes all Batch progress bars and associated Upload sub-bars."""
282+
global BATCH_BARS, TOTAL_INGRESS_BAR # noqa F824
283+
282284
with BATCH_SEMAPHORE:
283285
for batch_pbar in BATCH_BARS:
284286
batch_pbar.upload_pbar.close()
285287
batch_pbar.close()
288+
289+
BATCH_BARS.clear()
290+
TOTAL_INGRESS_BAR = None

src/pds/ingress/util/report_util.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def update_summary_table(summary_table, key, paths):
5050
The key in the summary table to update (e.g., "uploaded", "skipped", "failed").
5151
paths : str or list of str
5252
The path value (or values) to add to the summary table for the specified key.
53+
Note, these paths should be the absolute paths to files that were processed,
54+
not the "trimmed" relative paths.
5355
5456
"""
5557
if key not in ("uploaded", "skipped", "failed", "unprocessed"):

0 commit comments

Comments
 (0)