feat: Push events on their own goroutines instead of blocking the drain - #72
Open
keelerm84 wants to merge 1 commit into
Open
feat: Push events on their own goroutines instead of blocking the drain#72keelerm84 wants to merge 1 commit into
keelerm84 wants to merge 1 commit into
Conversation
eventLoop drained a batch and pushed it inline, so the push held up the next drain. With HTTP_TIMEOUT at 30s and two attempts a second apart, one degraded cycle could keep the loop for around two minutes, and every second of that is a second EventData__c is not drained. That matters because the default EventSink caps the table at 1000 rows and then stops recording silently, so a slow events endpoint turns into dropped events in the org. The push now runs on its own goroutine, bounded at MAX_EVENT_FLUSH_WORKERS, matching maxFlushWorkers in the other LaunchDarkly server SDKs. The retry sequence moves with it unchanged. The slot is taken before the drain, not after, and that ordering is the design rather than a detail. EventREST.prepareEvents deletes the rows as it hands them over, so a batch drained with no free push is a batch with nowhere to go. Declining to drain leaves the events in EventData__c instead. The SDKs make the same choice by keeping events in their outbox when every flush worker is busy; here the org's table is that outbox. Two consequences needed handling. A push goroutine cannot return from eventLoop, so a rejected SDK key is reported through flushFatal and the loop answers with it, keeping the existing message and the non-zero exit. And a push still running at shutdown holds events Salesforce has already deleted, so the loop waits FLUSH_DRAIN_GRACE for it and says how many it abandoned; unbounded waiting would let HTTP_TIMEOUT alone make a shutdown look like a hang. Two test-side fixes the change forced. newEventPushBridge set the poll interval and the retry delay both to one millisecond, which was harmless while the push was inline but now races the fixture: the drain that cancels the bridge could land inside a retry delay and abandon it. The interval is now well clear of the delay, keeping the shape production has at 1s against 30s. And captureLog handed back a bytes.Buffer that a test could read while the loop was still writing to it, which the race detector caught once a push ran concurrently; it is now mutex-guarded. Each new property was checked by mutation: draining before taking a slot loses batches, never releasing a slot silences the loop, and skipping the shutdown wait abandons an in-flight push. Green on Go 1.15 (CI) and 1.26, under -race -count=10 and -shuffle=on.
kinyoklion
reviewed
Aug 31, 2026
Comment on lines
+18
to
+21
| // batch drained with nowhere to send it is a batch lost. The bridge therefore declines to | ||
| // drain at all, which leaves the events in the org -- the same choice the other | ||
| // LaunchDarkly SDKs make by keeping events in their outbox when every flush worker is | ||
| // busy. TestDrainIsSkippedWhenEveryFlushSlotIsBusy is that assertion. |
Member
There was a problem hiding this comment.
Suggested change
| // batch drained with nowhere to send it is a batch lost. The bridge therefore declines to | |
| // drain at all, which leaves the events in the org -- the same choice the other | |
| // LaunchDarkly SDKs make by keeping events in their outbox when every flush worker is | |
| // busy. TestDrainIsSkippedWhenEveryFlushSlotIsBusy is that assertion. | |
| // batch drained with nowhere to send it is a batch lost. The bridge therefore declines to | |
| // drain at all, which leaves the events in the org. | |
| // TestDrainIsSkippedWhenEveryFlushSlotIsBusy is that assertion. |
I wouldn't say the situation is directly analogous. They would leave the buffer alone, but the buffer would be bounded. So eventually they just stop processing events.
Is there any protection about how many events will accumulate if something goes wrong with the flushers?
kinyoklion
reviewed
Aug 31, 2026
| return | ||
| } | ||
|
|
||
| recoverable := isHTTPErrorRecoverable(pushResponse.StatusCode) |
Member
There was a problem hiding this comment.
Typically, for events, we have a locally recoverable function which is a bit different than the standard recoverable statuses.
Member
There was a problem hiding this comment.
So that we don't retry a 413 with the same too large payload.
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.
Summary
eventLoopdrained a batch and pushed it inline, so the push held up the next drain. WithHTTP_TIMEOUTat its 30s default and two attempts a second apart, one degraded cycle couldkeep the loop for around two minutes:
The interval is
time.Aftermeasured after the work rather than a ticker, so the cycleperiod is always work plus 30s. Every second the push holds is a second
EventData__cisnot drained -- and the default
EventSinkcaps that table at 1000 rows and then stopsrecording silently, so a slow events endpoint turns into dropped events in the org.
The push now runs on its own goroutine, bounded at
MAX_EVENT_FLUSH_WORKERS = 5, matchingmaxFlushWorkersingo-sdk-events. The retry sequence moves with it unchanged.The slot is taken before the drain, and that ordering is the design
EventREST.prepareEventsdeletes the rows as it hands them over, so a batch drained with nofree push is a batch with nowhere to go. A non-blocking hand-off after draining would leave
only "drop the batch" as the fallback -- real data loss.
Taking the slot first means that when all five are busy the bridge declines to drain at all,
and this cycle's events stay in
EventData__c. That is the same choice the other SDKs make:triggerFlushtakes itsdefaultbranch and does not clear the outbox, pinned there byTestEventsAreKeptInBufferIfAllFlushWorkersAreBusy. The bridge has no in-memory outbox --the org's table is the outbox.
TestDrainIsSkippedWhenEveryFlushSlotIsBusyasserts on the drain count rather than thepush count for exactly this reason: a bridge that drained and then dropped the batch looks
identical from the push side.
Two consequences that needed handling
A push goroutine cannot return from
eventLoop. A rejected SDK key is reported throughflushFataland the loop answers with it, preserving both the existing message and thenon-zero exit from #57. It may surface up to one poll interval later than before, since the
loop notices at its next wake.
A push still running at shutdown holds events Salesforce has already deleted. The loop
waits
FLUSH_DRAIN_GRACE(5s) for in-flight pushes and logs how many it abandoned. The waitis capped rather than open-ended because
HTTP_TIMEOUTalone allows 30s per attempt, and ashutdown that takes that long is indistinguishable from a hang.
Two test-side problems the change forced
newEventPushBridgeset the poll interval and the retry delay both to one millisecond. Thatwas harmless while the push was inline, because the loop could not reach the next drain until
the push finished. Now they are concurrent, so the drain that cancels the bridge could land
inside a retry delay and abandon it -- the existing retry tests went intermittent under
-count=20. The interval is now well clear of the delay, keeping the shape production has at1s against 30s.
captureLoghanded back abytes.Bufferthat a test could read while the loop was stillwriting to it.
log.Loggerserialises its own writes, so this was safe until a push ran onanother goroutine; the race detector caught it immediately after. It is mutex-guarded now,
which also closes the hazard for the existing tests that assert on log content.
Verification
Five new tests, each checked by mutation:
Slot release is covered on all four push outcomes (success, exhausted retries,
non-recoverable status, dropped connection) by driving more batches through than there are
slots, since a leak is invisible below that threshold.
Two of the new tests originally hung on their failure path rather than reporting -- blocked
handlers deadlocking
httptest.Server.Close-- so their gates are now released before theserver closes and a real failure reports in milliseconds instead of timing out.
Green on Go 1.15 (CI) and 1.26, under
-race -count=10, and-shuffle=on.Not addressed
Delivery is still not durable. Salesforce deletes on read, so a batch that exhausts both
attempts is gone. This changes where a batch waits, not whether it can be lost. Batches can
also reach LaunchDarkly out of order now, which is acceptable: the SDKs already flush in
parallel, events carry their own timestamps, and each batch keeps its own payload ID so
per-batch retry dedup is unaffected.
Note
Overview
Event delivery no longer blocks Salesforce drains.
eventLoopused to push each batch to LaunchDarkly inline, so a slow events API could stall the next drain for minutes and back upEventData__c. Pushes now run influshEventson separate goroutines, capped atMAX_EVENT_FLUSH_WORKERS(5) via aflushSlotstoken pool, aligned with other LaunchDarkly server SDKs.Backpressure avoids silent batch loss. Because drains delete rows in Salesforce, the bridge acquires a flush slot before draining. When all workers are busy it skips the drain and leaves events in the org (same pattern as SDK outbox behavior), with logging when it declines.
Shutdown and fatal errors from workers.
eventLoopawaitFlushesfor up toFLUSH_DRAIN_GRACE(5s) so in-flight pushes can finish after cancel. Push goroutines report 401/403 and similar stop-the-daemon failures throughflushFatalsoeventLoopstill exits with the right error.Tests and harness fixes. New
flush_workers_test.gocovers skip-drain under saturation, slot release on all outcomes, non-blocking drains, distinct payload IDs under concurrency, and shutdown waiting. Retry tests use a poll interval well above the retry delay to avoid races;captureLoguses a mutex-backed buffer for concurrent push logging.Reviewed by Cursor Bugbot for commit 2d5040b. Bugbot is set up for automated code reviews on this repo. Configure here.