Skip to content

Commit c2596cb

Browse files
sir-sigurdclaude
andauthored
pkgevents: drop s3:TestEvent silently, report other failures per message (#5162)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eda3016 commit c2596cb

3 files changed

Lines changed: 202 additions & 68 deletions

File tree

lambdas/pkgevents/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ where verb is one of
1717

1818
## Changes
1919

20+
- [Fixed] Drop `s3:TestEvent` messages and report other failures per message via `ReportBatchItemFailures`, instead of failing the whole SQS batch and dead-lettering the real package events in it ([#5162](https://github.com/quiltdata/quilt/pull/5162))
2021
- [Fixed] Process package pointers from year 2026+ ([#4683](https://github.com/quiltdata/quilt/pull/4683))
2122
- [Changed] Migrate to proper package structure ([#4647](https://github.com/quiltdata/quilt/pull/4647))
2223
- [Changed] Switch to uv ([#4647](https://github.com/quiltdata/quilt/pull/4647))

lambdas/pkgevents/src/t4_lambda_pkgevents/__init__.py

Lines changed: 58 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,17 @@
99

1010
EXPECTED_POINTER_SIZE = 64
1111

12+
# S3 sends this to the notification queue when a bucket's notification
13+
# configuration is created; it carries no 'Records'.
14+
TEST_EVENT = 's3:TestEvent'
15+
16+
PUT_EVENTS_MAX_ENTRIES = 10 # PutEvents API limit
17+
1218
event_bridge = boto3.client('events')
1319
s3 = boto3.client('s3')
1420
logger = get_quilt_logger()
1521

1622

17-
class PutEventsException(Exception):
18-
pass
19-
20-
21-
class EventsQueue:
22-
MAX_SIZE = 10
23-
24-
def __init__(self):
25-
self._events = []
26-
27-
def append(self, event):
28-
self._events.append(event)
29-
if len(self) >= self.MAX_SIZE:
30-
self._flush()
31-
32-
def _flush(self):
33-
events = self._events
34-
self._events = []
35-
resp = event_bridge.put_events(Entries=events)
36-
if resp['FailedEntryCount']:
37-
raise PutEventsException(resp)
38-
39-
def flush(self):
40-
if self:
41-
self._flush()
42-
43-
def __len__(self):
44-
return len(self._events)
45-
46-
def __bool__(self):
47-
return bool(self._events)
48-
49-
5023
PKG_POINTER_REGEX = re.compile(r'\.quilt/named_packages/([\w-]+/[\w-]+)/([0-9]{10})')
5124

5225

@@ -94,13 +67,56 @@ def pkg_created_event(s3_event):
9467
}
9568

9669

70+
def get_s3_events(body):
71+
"""Return the S3 events from an SQS message body ([] for S3 test events);
72+
raise for any other unexpected shape."""
73+
body = json.loads(body)
74+
if body.get('Event') == TEST_EVENT:
75+
# arrives on every bucket add, not worth logging
76+
return []
77+
return body['Records']
78+
79+
80+
def publish(entries):
81+
"""Publish (message id, event) pairs to EventBridge and return the ids of
82+
the messages whose events failed to publish."""
83+
failed_message_ids = set()
84+
for chunk in itertools.batched(entries, PUT_EVENTS_MAX_ENTRIES):
85+
resp = event_bridge.put_events(Entries=[event for _, event in chunk])
86+
if resp['FailedEntryCount']:
87+
# response entries are in the same order as the request entries
88+
for (message_id, _), resp_entry in zip(chunk, resp['Entries'], strict=True):
89+
if 'ErrorCode' in resp_entry:
90+
logger.warning('failed to publish event from message %s: %s', message_id, resp_entry)
91+
failed_message_ids.add(message_id)
92+
return failed_message_ids
93+
94+
9795
def handler(event, context):
98-
s3_events = itertools.chain.from_iterable(
99-
json.loads(record['body'])['Records']
100-
for record in event['Records']
101-
)
102-
queue = EventsQueue()
103-
for event in filter(None, map(pkg_created_event, s3_events)):
104-
queue.append(event)
105-
106-
queue.flush()
96+
failed_message_ids = set()
97+
entries = []
98+
for record in event['Records']:
99+
message_id = record['messageId']
100+
try:
101+
entries += [
102+
(message_id, pkg_event)
103+
for pkg_event in map(pkg_created_event, get_s3_events(record['body']))
104+
if pkg_event is not None
105+
]
106+
except Exception:
107+
# unexpected message shape (S3 is the expected producer, not the only
108+
# possible one) or a failure while processing one of its events: fail
109+
# the message alone so it's retried and dead-lettered by itself,
110+
# ending up in the DLQ instead of being deleted without a trace
111+
logger.exception('failed to process message %s', message_id)
112+
failed_message_ids.add(message_id)
113+
114+
failed_message_ids |= publish(entries)
115+
# Messages not listed here are considered processed and get deleted from the
116+
# queue, so an error that can't be attributed to specific messages (a failed
117+
# put_events call) must raise, failing the whole batch.
118+
return {
119+
'batchItemFailures': [
120+
{'itemIdentifier': message_id} for message_id in failed_message_ids
121+
],
122+
}

lambdas/pkgevents/tests/test_index.py

Lines changed: 143 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
from botocore.stub import Stubber
88

99
from t4_lambda_pkgevents import (
10-
EventsQueue,
11-
PutEventsException,
10+
PUT_EVENTS_MAX_ENTRIES,
1211
handler,
1312
pkg_created_event,
13+
publish,
1414
s3,
1515
)
1616

@@ -146,44 +146,161 @@ def test_pkg_created_event(pointer_file):
146146
stubber.assert_no_pending_responses()
147147

148148

149-
@mock.patch("t4_lambda_pkgevents.EventsQueue.flush")
150-
@mock.patch("t4_lambda_pkgevents.EventsQueue.append")
149+
@mock.patch("t4_lambda_pkgevents.publish", return_value=frozenset())
151150
@mock.patch("t4_lambda_pkgevents.pkg_created_event", wraps=str)
152-
def test_handler(pkg_created_event_mock, queue_append_mock, queue_flush_mock):
151+
def test_handler(pkg_created_event_mock, publish_mock):
153152
event = {
154153
'Records': [
155154
{
155+
'messageId': f'message-{idx}',
156156
'body': json.dumps(
157157
{
158158
'Records': records
159159
}
160160
)
161161
}
162-
for records in (
163-
(0, 1),
164-
(2, 3, 4),
165-
(5,)
162+
for idx, records in enumerate(
163+
(
164+
(0, 1),
165+
(2, 3, 4),
166+
(5,)
167+
)
166168
)
167169
]
168170
}
169-
handler(event, None)
171+
assert handler(event, None) == {'batchItemFailures': []}
170172
assert pkg_created_event_mock.call_args_list == [((x,),) for x in range(6)]
171-
assert queue_append_mock.call_args_list == [((str(x),),) for x in range(6)]
172-
queue_flush_mock.assert_called_once_with()
173+
publish_mock.assert_called_once_with(
174+
[
175+
(f'message-{idx}', str(x))
176+
for x, idx in zip(range(6), (0, 0, 1, 1, 1, 2), strict=True)
177+
]
178+
)
179+
180+
181+
@mock.patch("t4_lambda_pkgevents.logger")
182+
@mock.patch("t4_lambda_pkgevents.publish", return_value=frozenset())
183+
@mock.patch("t4_lambda_pkgevents.pkg_created_event", wraps=str)
184+
def test_handler_drops_test_events_silently(
185+
pkg_created_event_mock, publish_mock, logger_mock
186+
):
187+
test_event_body = json.dumps(
188+
{
189+
'Service': 'Amazon S3',
190+
'Event': 's3:TestEvent',
191+
'Time': '2026-07-30T00:38:18.000Z',
192+
'Bucket': 'test-bucket',
193+
'RequestId': 'AAAAAAAAAAAAAAAA',
194+
'HostId': 'aaaaaaaaaaaaaaaa',
195+
}
196+
)
197+
event = {
198+
'Records': [
199+
{'messageId': 'message-0', 'body': json.dumps({'Records': (0, 1)})},
200+
{'messageId': 'message-1', 'body': test_event_body},
201+
{'messageId': 'message-2', 'body': json.dumps({'Records': (2,)})},
202+
]
203+
}
204+
assert handler(event, None) == {'batchItemFailures': []}
205+
assert pkg_created_event_mock.call_args_list == [((x,),) for x in range(3)]
206+
publish_mock.assert_called_once_with(
207+
[
208+
(message_id, str(x))
209+
for x, message_id in zip(range(3), ('message-0', 'message-0', 'message-2'), strict=True)
210+
]
211+
)
212+
# a test event arrives on every bucket add, it must not be logged
213+
logger_mock.warning.assert_not_called()
214+
logger_mock.exception.assert_not_called()
215+
216+
217+
@pytest.mark.parametrize(
218+
'bad_body',
219+
(
220+
json.dumps({'Event': 'unexpected'}),
221+
'not JSON',
222+
'null',
223+
'[]',
224+
'"s3:TestEvent"',
225+
),
226+
)
227+
@mock.patch("t4_lambda_pkgevents.publish", return_value=frozenset())
228+
@mock.patch("t4_lambda_pkgevents.pkg_created_event", wraps=str)
229+
def test_handler_reports_messages_without_records_as_failed(
230+
pkg_created_event_mock, publish_mock, bad_body
231+
):
232+
event = {
233+
'Records': [
234+
{'messageId': 'message-0', 'body': json.dumps({'Records': (0,)})},
235+
{'messageId': 'message-1', 'body': bad_body},
236+
]
237+
}
238+
assert handler(event, None) == {'batchItemFailures': [{'itemIdentifier': 'message-1'}]}
239+
assert pkg_created_event_mock.call_args_list == [((0,),)]
240+
publish_mock.assert_called_once_with([('message-0', '0')])
173241

174242

175-
@pytest.mark.parametrize('failed_count', (0, 1))
176-
def test_queue(failed_count):
243+
@mock.patch("t4_lambda_pkgevents.publish", return_value=frozenset())
244+
@mock.patch("t4_lambda_pkgevents.pkg_created_event")
245+
def test_handler_reports_message_with_failing_event(
246+
pkg_created_event_mock, publish_mock
247+
):
248+
pkg_created_event_mock.side_effect = (None, Exception('boom'), None)
249+
event = {
250+
'Records': [
251+
# the event after the failing one must not be processed
252+
{'messageId': 'message-0', 'body': json.dumps({'Records': (0, 1, 2)})},
253+
{'messageId': 'message-1', 'body': json.dumps({'Records': (3,)})},
254+
]
255+
}
256+
assert handler(event, None) == {'batchItemFailures': [{'itemIdentifier': 'message-0'}]}
257+
assert pkg_created_event_mock.call_args_list == [((x,),) for x in (0, 1, 3)]
258+
publish_mock.assert_called_once_with([])
259+
260+
261+
@mock.patch("t4_lambda_pkgevents.publish", return_value=frozenset({'message-0'}))
262+
@mock.patch("t4_lambda_pkgevents.pkg_created_event", wraps=str)
263+
def test_handler_reports_publish_failures(
264+
pkg_created_event_mock, publish_mock
265+
):
266+
event = {
267+
'Records': [
268+
{'messageId': 'message-0', 'body': json.dumps({'Records': (0,)})},
269+
]
270+
}
271+
assert handler(event, None) == {'batchItemFailures': [{'itemIdentifier': 'message-0'}]}
272+
273+
274+
def test_publish_success():
177275
with mock.patch("t4_lambda_pkgevents.event_bridge.put_events") as put_events_mock:
178-
put_events_mock.return_value = {'FailedEntryCount': failed_count}
179-
q = EventsQueue()
180-
for x in range(EventsQueue.MAX_SIZE - 1):
181-
q.append(x)
182-
put_events_mock.assert_not_called()
183-
184-
if failed_count:
185-
with pytest.raises(PutEventsException):
186-
q.append(EventsQueue.MAX_SIZE - 1)
187-
else:
188-
q.append(EventsQueue.MAX_SIZE - 1)
189-
put_events_mock.assert_called_once_with(Entries=list(range(EventsQueue.MAX_SIZE)))
276+
put_events_mock.return_value = {'FailedEntryCount': 0}
277+
entries = [(f'message-{x}', x) for x in range(PUT_EVENTS_MAX_ENTRIES + 1)]
278+
279+
assert publish(entries) == set()
280+
assert put_events_mock.call_args_list == [
281+
mock.call(Entries=list(range(PUT_EVENTS_MAX_ENTRIES))),
282+
mock.call(Entries=[PUT_EVENTS_MAX_ENTRIES]),
283+
]
284+
285+
286+
def test_publish_nothing():
287+
with mock.patch("t4_lambda_pkgevents.event_bridge.put_events") as put_events_mock:
288+
assert publish([]) == set()
289+
put_events_mock.assert_not_called()
290+
291+
292+
def test_publish_failed_entries():
293+
with mock.patch("t4_lambda_pkgevents.event_bridge.put_events") as put_events_mock:
294+
first_entries = [{'EventId': str(x)} for x in range(PUT_EVENTS_MAX_ENTRIES)]
295+
first_entries[3] = {'ErrorCode': 'ThrottlingException', 'ErrorMessage': 'try later'}
296+
put_events_mock.side_effect = (
297+
{'FailedEntryCount': 1, 'Entries': first_entries},
298+
{
299+
'FailedEntryCount': 1,
300+
'Entries': [{'ErrorCode': 'InternalFailure', 'ErrorMessage': 'oops'}],
301+
},
302+
)
303+
entries = [(f'message-{x}', x) for x in range(PUT_EVENTS_MAX_ENTRIES + 1)]
304+
305+
assert publish(entries) == {'message-3', f'message-{PUT_EVENTS_MAX_ENTRIES}'}
306+
assert put_events_mock.call_count == 2

0 commit comments

Comments
 (0)