Skip to content

Cap event capture requests at 100 events - #46

Merged
bpapillon merged 1 commit into
mainfrom
fix/cap-event-batch-size
Aug 20, 2026
Merged

Cap event capture requests at 100 events#46
bpapillon merged 1 commit into
mainfrom
fix/cap-event-batch-size

Conversation

@ryanechternacht

Copy link
Copy Markdown
Member

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_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. Puma is threaded by default, so this is reachable in an ordinary Rails app.

Changes

Route both flush and drain_pending through a new 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 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 customtest or rubocop. What I did instead:

  1. Exercised the real EventBuffer class standalone under 2.6 (the file is stdlib-only), stubbing post_to_capture_service to record request sizes. Both scenarios behave correctly with the change and incorrectly without it:

    scenario without fix with fix
    250-event backlog drained [250] [100, 100, 50]
    events piling up behind an in-flight send [100, 150] [100, 100, 50]

    The 150 in that second row is the bug: a request the server would reject.

  2. Syntax-checked the added test block in isolation (ruby -c on 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_return block 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-node and lost the whole batch to a 400. I checked every SDK with an event buffer: schematic-go, schematic-java, and schematic-csharp already hard-cap the drain and need no change. schematic-node (#162) and schematic-python (#98) have their own PRs. schematic-js sends events one at a time and is unaffected.

Unrelated thing I noticed

lib/schematic/event_buffer.rb calls Time.now.utc.iso8601 but does not require "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

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>
@ryanechternacht
ryanechternacht requested a review from cbrady August 19, 2026 19:36
@ryanechternacht ryanechternacht self-assigned this Aug 19, 2026
@bpapillon
bpapillon merged commit ae2a1b3 into main Aug 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants