Cap event capture requests at 100 events - #46
Merged
Conversation
The capture service rejects any batch over 100 events with
`400 {"error": "batch too large", "max_size": 100}`, but both send sites
handed it the entire drained backlog.
@max_batch_size does not bound that backlog. push appends
unconditionally, and the flush it triggers returns immediately while
another flush is in flight:
def flush
@mutex.synchronize do
return if @flushing || @events.empty?
so every event pushed during an in-flight request accumulates, and
drain_pending then sends the whole pile as one request. With two
producer threads and a slow send this is unbounded, growing with request
duration times push rate.
Route both flush and drain_pending through send_capped, which slices the
drained set into requests of at most 100 events. Each chunk retries on
its own, since retrying the whole set would resend chunks that had
already been delivered.
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>
cbrady
approved these changes
Aug 19, 2026
bpapillon
approved these changes
Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The capture service rejects any batch over 100 events with
400 {"error": "batch too large", "max_size": 100}(capture.go:73), but both send sites handed it the entire drained backlog.@max_batch_sizedoes not bound that backlog.pushappends unconditionally, and the flush it triggers returns immediately while another flush is in flight:So every event pushed during an in-flight request accumulates, and
drain_pendingthen sends the whole pile as one request. With two producer threads and a slow send this is unbounded, growing with request duration times push rate. Puma is threaded by default, so this is reachable in an ordinary Rails app.Changes
Route both
flushanddrain_pendingthrough a newsend_capped, which slices the drained set into requests of at most 100 events. Each chunk retries on its own, since retrying the whole set would resend chunks that had already been delivered.This matches
schematic-go,schematic-java, andschematic-csharp, which all bound the drain rather than relying on the buffer size to stay small.Verification, and what I could not run
Please lean on CI for the test file. This gem requires Ruby >= 3.3 and the only interpreter on my machine is system Ruby 2.6, so I could not run
rake customtestor rubocop. What I did instead:Exercised the real
EventBufferclass standalone under 2.6 (the file is stdlib-only), stubbingpost_to_capture_serviceto record request sizes. Both scenarios behave correctly with the change and incorrectly without it:[250][100, 100, 50][100, 150][100, 100, 50]The
150in that second row is the bug: a request the server would reject.Syntax-checked the added test block in isolation (
ruby -con the whole file fails under 2.6 on pre-existing Ruby 3 endless method definitions at line 32, unrelated to this change).The part I could not verify is whether webmock's
to_returnblock behaves as expected when held open across threads in the second test. If CI goes red, that is where I would look first.Cross-SDK status
Found while fixing a daily cron that tracked 113 companies through
schematic-nodeand lost the whole batch to a 400. I checked every SDK with an event buffer:schematic-go,schematic-java, andschematic-csharpalready hard-cap the drain and need no change.schematic-node(#162) andschematic-python(#98) have their own PRs.schematic-jssends events one at a time and is unaffected.Unrelated thing I noticed
lib/schematic/event_buffer.rbcallsTime.now.utc.iso8601but does notrequire "time". It works today because something else in the gem requires it first. Worth adding, but I left it out to keep this diff focused.🤖 Generated with Claude Code