Skip to content

Commit aebe20b

Browse files
Cap event capture requests at 100 events
The capture service rejects any batch over 100 events with `400 {"error": "batch too large", "max_size": 100}`, but _flush drained the whole backlog into a single send_batch call. max_events already defaults to 100, so the single-producer path stays under the cap. It is not a hard guarantee though: push() appends unconditionally after its own flush, if should_flush: self._flush() with self.lock: self.events.append(event) so producers piling up behind an in-flight send can drive the backlog past max_events, and a caller may also raise max_events themselves. Chunk each flush into requests of at most 100 events in both the sync and async buffers, so the cap holds regardless of max_events or producer concurrency. This matches schematic-go, schematic-java, and schematic-csharp, which all bound the drain rather than the buffer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d9d9a6b commit aebe20b

2 files changed

Lines changed: 58 additions & 4 deletions

File tree

src/schematic/event_buffer.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
from .types import CreateEventRequestBody
1010

1111
DEFAULT_MAX_EVENTS = 100 # Default maximum number of events
12+
# The capture service rejects any batch larger than this with
13+
# `400 {"error": "batch too large", "max_size": 100}`, so a flush is split into
14+
# chunks of at most this many events regardless of how many are buffered.
15+
MAX_EVENTS_PER_REQUEST = 100
1216
DEFAULT_EVENT_BUFFER_PERIOD = 5 # 5 seconds
1317
DEFAULT_MAX_RETRIES = 3 # Default maximum number of retry attempts
1418
DEFAULT_INITIAL_RETRY_DELAY = 1 # Initial retry delay in seconds
@@ -47,8 +51,14 @@ def _flush(self):
4751
events_to_process = [event for event in self.events if event is not None]
4852
self.events.clear()
4953

50-
if events_to_process:
51-
self._process_events(events_to_process)
54+
# The buffer can hold more than one request's worth of events. push()
55+
# appends unconditionally after its own flush, so concurrent producers
56+
# can drive the backlog past max_events. Send in chunks so an oversized
57+
# buffer is never turned into an oversized request. Each chunk retries
58+
# on its own, since retrying the whole drained set would resend chunks
59+
# that already succeeded.
60+
for i in range(0, len(events_to_process), MAX_EVENTS_PER_REQUEST):
61+
self._process_events(events_to_process[i:i + MAX_EVENTS_PER_REQUEST])
5262

5363
def _process_events(self, events_to_process):
5464
"""Process events with retry logic - called without holding lock"""
@@ -153,8 +163,10 @@ async def _flush(self):
153163
events_to_process = [event for event in self.events if event is not None]
154164
self.events.clear()
155165

156-
if events_to_process:
157-
await self._process_events_async(events_to_process)
166+
# See EventBuffer._flush: the buffer can exceed max_events, so cap the
167+
# size of each request rather than sending the whole drained backlog.
168+
for i in range(0, len(events_to_process), MAX_EVENTS_PER_REQUEST):
169+
await self._process_events_async(events_to_process[i:i + MAX_EVENTS_PER_REQUEST])
158170

159171
async def _process_events_async(self, events_to_process):
160172
"""Process events with retry logic - called without holding lock"""

tests/custom/test_event_buffer.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,45 @@ async def test_push_after_shutdown_rejected(self):
321321

322322
if __name__ == "__main__":
323323
unittest.main()
324+
325+
326+
class TestEventBufferBatchSizeCap(unittest.TestCase):
327+
"""The capture service rejects batches over 100 events, so no single
328+
send_batch call may exceed that no matter how deep the backlog is."""
329+
330+
def setUp(self):
331+
self.mock_sender = MagicMock()
332+
self.mock_logger = MagicMock()
333+
334+
def _batch_sizes(self):
335+
return [len(call.args[0]) for call in self.mock_sender.send_batch.call_args_list]
336+
337+
def test_flush_splits_backlog_into_capped_requests(self):
338+
buffer = EventBuffer(
339+
event_sender=self.mock_sender, logger=self.mock_logger, period=3600, max_events=1000
340+
)
341+
try:
342+
buffer.events = [MagicMock(spec=CreateEventRequestBody) for _ in range(250)]
343+
buffer._flush()
344+
finally:
345+
buffer.stop()
346+
347+
self.assertEqual(self._batch_sizes(), [100, 100, 50])
348+
349+
350+
class TestAsyncEventBufferBatchSizeCap(unittest.TestCase):
351+
352+
def test_flush_splits_backlog_into_capped_requests(self):
353+
async def run():
354+
mock_sender = AsyncMock()
355+
buffer = AsyncEventBuffer(
356+
event_sender=mock_sender, logger=MagicMock(), period=3600, max_events=1000
357+
)
358+
try:
359+
buffer.events = [MagicMock(spec=CreateEventRequestBody) for _ in range(250)]
360+
await buffer._flush()
361+
finally:
362+
await buffer.stop()
363+
return [len(call.args[0]) for call in mock_sender.send_batch.call_args_list]
364+
365+
self.assertEqual(asyncio.run(run()), [100, 100, 50])

0 commit comments

Comments
 (0)