Skip to content

Commit d70d274

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

6 files changed

Lines changed: 201 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(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(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(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: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@
77
automatic backoff/retry of HTTP requests.
88
99
"""
10+
import random
11+
import requests_mock
12+
13+
from contextlib import contextmanager
14+
from distutils.util import strtobool
1015
from http import HTTPStatus
1116

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