Skip to content

feat(events): summarize and buffer events on the recording thread (tier 1) - #397

Open
abelonogov-ld wants to merge 40 commits into
mainfrom
andrey/event-durability-tier1-buffer
Open

abelonogov-ld wants to merge 40 commits into
mainfrom
andrey/event-durability-tier1-buffer

Conversation

@abelonogov-ld

@abelonogov-ld abelonogov-ld commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Requirements

  • I have added test coverage for new or changed functionality
  • I have followed the repository's pull request submission guidelines
  • I have validated my changes against all supported platform versions

Related issues

Fixes #281. DefaultEventProcessor.close() waited with no limit for its final flush, which on a slow network blocked the calling thread long enough to trigger an ANR. DirectEventProcessor.close() waits at most two seconds for that delivery and then lets it finish in the background.

Tier 1 of the Event Durability spec, "Tier 1 — the buffer". Tier 2 is stacked on this branch as #403; tier 3 will follow.

Describe the solution you've provided

DirectEventProcessor takes over from java-sdk-internal's DefaultEventProcessor in ComponentsImpl, and OutboundEventBuffer replaces the bounded queue that used to sit between the calling thread and the summarizer.

That queue is the problem this tier removes. An evaluation handed its event to an ArrayBlockingQueue and a dispatcher thread summarized it on the far side, so a burst of evaluations of untracked flags could fill the queue and displace the track and identify events an application actually asked to send. Summarizing on the recording thread means an evaluation of an untracked flag costs a counter increment and can never displace anything, because it never occupies a slot in the first place.

OutboundEventBuffer holds in full only the events that have to be sent one by one. It reuses java-sdk-internal's EventOutputFormatter and summarizer implementations rather than reimplementing them, so there stays exactly one definition of what an event looks like on the wire.

Two smaller changes ride along, both prerequisites rather than additions:

  • java-sdk-internal moves to 1.12.0, which makes the summarizer and formatter types public so the buffer can use them from its own package.
  • Diagnostic events are no longer sent while the SDK is offline or the application is backgrounded.

Describe alternatives you've considered

Reuse DefaultEventProcessor and raise its queue capacity. This moves the threshold without removing it. The displacement is a property of having a bounded handoff between the caller and the summarizer at all, and a render loop re-evaluating a tracked flag will reach any capacity you pick.

Keep the buffer inside java-sdk-internal's package to reach its package-private types. This worked, but it relied on a split package, which breaks Java modules and needed a Javadoc exclusion to build at all. Making the types public upstream was the durable fix, and is why the dependency bump is here.

Additional context

Naming. DirectEventProcessor and OutboundEventBuffer rather than an Android prefix: that prefix in this package is reserved for adapters over Android OS APIs, such as AndroidPlatformState and AndroidTaskExecutor, and neither of these classes touches one. "Direct" names the contrast with DefaultEventProcessor — nothing sits between the caller and the summarizer. DefaultEventProcessor was unavailable as a name because java-sdk-internal already exports it and this package imports from it.

Tests. DirectEventProcessorTest, EventProcessorBufferingTest and EventProcessorPrivacyTest share an EventProcessorTestBase, adding 22 tests across buffering, capacity, private-attribute redaction and diagnostics. The full unit suite is 752 tests, all passing locally.

Platform validation. The requirements box above is left unchecked deliberately. Behaviour is covered by the unit suite, but performance was measured on one physical device rather than across the supported API range.

Example app. Gains an "Eval+track+kill" button that evaluates a flag, tracks an event, flushes, and then kills the process five seconds later. That is the reproduction for the loss this work addresses, and it still loses the events on this tier — tier 3 is what makes them survive. Its LDClient.init call also moves off the deprecated three-argument overload.


Note

Overview
Replaces the Android SDK’s analytics pipeline wrapper around java-sdk-internal’s DefaultEventProcessor with DirectEventProcessor and OutboundEventBuffer, so flag evaluations are summarized on the recording thread and only full-fidelity events consume buffer capacity. This removes the old bounded inbox queue that could drop summaries or crowd out track/identify during evaluation bursts.

ComponentsImpl now builds DirectEventProcessor with a dedicated diagnostics posting thread, a 2s close budget for final delivery (then continues in the background), and stricter shutdown/offline rules documented on EventProcessor. EventProcessorBuilder.capacity clamps values below 1 to 1. launchdarklyJavaSdkInternal is bumped to 1.12.0 so summarizer/formatter types can be reused from the new buffer.

The example app switches to blocking LDClient.init(..., INIT_WAIT_SECONDS) instead of awaiting a Future. A new internal test-app module (dexcount kept there) adds an Eval+track+kill harness for unsent-event scenarios; example drops dexcount. .gitignore now ignores local.properties without a leading slash.

Tests: large new suite (DirectEventProcessorTest, buffering/privacy/flags tests, shared EventProcessorTestBase) covering atomic record/flush, capacity, diagnostics, and redaction through the new path.

Reviewed by Cursor Bugbot for commit 15d3ffa. Bugbot is set up for automated code reviews on this repo. Configure here.

abelonogov-ld and others added 8 commits September 9, 2026 11:58
…er 1)

Tier 1 of the event durability spec: the buffer.

AndroidEventBuffer folds an evaluation into summary counters as it is recorded
and holds in full only the events that have to be sent one by one, so a burst of
evaluations of untracked flags cannot displace anything. AndroidEventProcessor
takes over from java-sdk-internal's DefaultEventProcessor in ComponentsImpl,
removing the bounded queue that used to sit between the calling thread and the
summarizer.

Spec: Event Durability, "Tier 1 — the buffer".
Co-authored-by: Cursor <cursoragent@cursor.com>
The next commit drops the split-package hack in favor of declarations that
launchdarkly/java-core#214 makes public. That is not in a release yet, so this
points the build at a working copy until one carries it.

Skipped when the directory is absent, so a clean checkout still resolves the
published artifact rather than failing. Revert together with a version bump once
the release is out.

Co-authored-by: Cursor <cursoragent@cursor.com>
…package

AndroidEventBuffer was declared in com.launchdarkly.sdk.internal.events so it
could reach the summarizers and the output formatter, which were package-private
there. That made a package split across two artifacts, forced the class to be
public for the rest of the SDK to use it, and needed a javadoc exclusion plus a
classpath workaround to keep it out of the published docs.

launchdarkly/java-core#214 makes those declarations public, so the class moves to
com.launchdarkly.sdk.android where it belongs. It is package-private now, along
with its members and Payload, because everything using it is in that package, so
it is out of the published docs by virtue of its visibility rather than by being
excluded from javadoc. The build.gradle javadoc block goes back to what it was
before tier 1.

No behavior change: same summarization, same wire format, same tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Diagnostics are meant to be off in both states, and updateScheduledTasks cancels
the periodic task accordingly, but two paths could still reach the sender after
the state had changed. Cancelling does not stop a run that has already begun, and
the init event is submitted to the executor while still online, so going offline
or backgrounding between submission and execution left it to send anyway.

Both paths now re-check on the way out, via a predicate that mirrors the
scheduling condition so the two cannot drift. The check in sendDiagnosticStats
sits ahead of createEventAndReset, which clears the counters it returns, so a
suspended period defers its statistics rather than discarding them.

Analytics delivery is unchanged: deliverPayload already skips while offline, and
it deliberately keeps running in the background so events recorded before the app
was backgrounded still get out.

Co-authored-by: Cursor <cursoragent@cursor.com>
The Android prefix in this package is reserved for adapters over Android OS APIs
- AndroidPlatformState, AndroidTaskExecutor, AndroidEnvironmentReporter. Neither
of these touches an Android API, so the prefix put them in the wrong category
while also repeating what the repository and package already say.

  AndroidEventProcessor -> DirectEventProcessor
  AndroidEventBuffer    -> OutboundEventBuffer

DirectEventProcessor names what sets it apart from java-sdk-internal's
DefaultEventProcessor: nothing sits between the caller and the summarizer.
OutboundEventBuffer says which direction the events are going, which matters
once tier 3 adds a store to read them back from.

DefaultEventProcessor was avoided as a name because java-sdk-internal already
exports that simple name and this package imports from it.

Rename only, including the test class. No behavior change.

Co-authored-by: Cursor <cursoragent@cursor.com>
- drop OutboundEventBuffer.isEmpty, which nothing calls
- StandardCharsets.UTF_8 in place of Charset.forName("UTF-8")
- scheduleWithFixedDelay, so a cached process does not come back owing every missed run
- split an over-long Javadoc sentence

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 123cb81)
@abelonogov-ld
abelonogov-ld requested a review from a team as a code owner September 18, 2026 17:23
abelonogov-ld added a commit that referenced this pull request Sep 18, 2026
**Requirements**

- [ ] I have added test coverage for new or changed functionality —
workflow-only change
- [x] I have followed the repository's pull request submission
guidelines
- [ ] I have validated my changes against all supported platform
versions — CI will validate the configured API level

**Related issues**

Unblocks
[#397](#397). Its
CI fails during Android SDK setup.

**Describe the solution you've provided**

Upgrade `android-actions/setup-android` from v3 to v4. Licenses are
already accepted successfully; the failure happens afterward because v3
invokes `sdkmanager tools`, and Google no longer publishes that obsolete
package. Version 4 removes `tools` from its default package list while
continuing to install command-line/platform tools and accept SDK
licenses.

**Describe alternatives you've considered**

Retrying cannot fix a package removed from Google's repository. Adding a
separate license-acceptance step would duplicate behavior and would not
address the failing `sdkmanager tools` invocation inside v3.

**Additional context**

Observed error: `Warning: Failed to find package 'tools'`. The upstream
v4 change explicitly fixes this failure.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> **Fixes CI Android SDK setup** by upgrading the shared composite CI
action from `android-actions/setup-android@v3` to **v4.0.4** (pinned to
commit `be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd`).
> 
> v3 fails after license acceptance because it still runs `sdkmanager
tools`, a package Google removed from the repository (`Failed to find
package 'tools'`). v4 stops requesting that obsolete package while still
installing the command-line/platform tooling needed for `./gradlew`
builds and the rest of the workflow.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d6a3b7e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

abelonogov-ld and others added 3 commits September 18, 2026 15:55
Bugbot flagged that diagnosticInitSent is set without consulting the
Result. That is deliberate: DefaultEventProcessor does the same, and a
failed post returns an unsuccessful Result rather than throwing, so the
flag flips either way. Note it at the assignment so the next reader does
not read it as an oversight.

Co-authored-by: Cursor <cursoragent@cursor.com>
Going offline cancels the periodic flush, so coming back online started a
fresh interval and left whatever the outage buffered waiting for it. Each
drop re-anchored that interval, so repeated brief losses could defer
delivery well past a single one.

Co-authored-by: Cursor <cursoragent@cursor.com>
java-sdk-internal's DefaultEventProcessor, which this path replaced,
skipped the summary counter for an event marked excludeFromSummaries.
OutboundEventBuffer.summarize counted unconditionally, so that flag was
the one thing the replacement did not carry over; addFullEvent already
honoured the sampling ratio, which is the sibling flag on the same
object.

Nothing observable changes today. DirectEventProcessor builds its
feature events through the constructor overload that fixes both
samplingRatio at 1 and excludeFromSummaries at false, so neither guard
can fire through the SDK's own recording path. The point is that the two
are defaulted by the same overload and would go live together: anything
that moves to the fourteen-argument constructor, which adopting sampling
requires, arms both at once. The failure mode is silent and arrives as
wrong analytics rather than as an error, since an event carries its
sampling ratio on the wire for the receiver to scale by.

The guard goes in the buffer rather than in the processor because the
buffer is where an event arrives from outside. A check in the processor
would sit three lines below the constructor call that hardcodes the
value, could never fire, and would read as dead. In the buffer it is
reachable, and OutboundEventBufferFlagsTest now covers both flags --
including sampling, which had no coverage at all.

Co-authored-by: Cursor <cursoragent@cursor.com>

@tanderson-ld tanderson-ld left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This went through a multi-perspective review (general / security / adversarial), with the java-sdk-internal 1.12.0 sources as the comparison baseline. The design fundamentals held up well under adversarial scrutiny — summarizer locking, capacity enforcement under concurrency, deadlock-freedom, diagnostics state transitions, and wire-format/privacy parity were all attacked and survived — but two proven error-path regressions versus DefaultEventProcessor need fixing before this merges, and a third cluster ties into an open field issue. Inline comments mark the specific spots; summary of the findings below.

Blocking

  1. A single unserializable event permanently wedges all analytics delivery. OutboundEventBuffer.drain() restores the summarizer and never clears events when serialization fails, so a deterministic failure re-fails every subsequent flush — periodic, manual, and close()'s final delivery — while the buffer saturates and silently drops all new identify/track events and the restored summaries grow without bound. Reachable from the public API today: trackMetric(..., Double.NaN) (no finiteness check on the path; Gson's strict writer rejects it) and boolVariation(null, ...) from Java (null key poisons the summarizer itself). DefaultEventProcessor cleared the outbox before serializing: it lost one batch and recovered on the next flush. Every failure this catch block can see is deterministic (a BAOS-backed writer can't fail transiently), so the restore can only ever produce a permanent wedge — suggest discard-with-loud-log on serialization failure, or per-event quarantine. A failing regression test is available (ProofPoisonedBufferRecoveryTest).

  2. An Error during a scheduled run silently and permanently stops all flushing. guarded() catches Exception, not Throwable; an Error escaping a periodic run (realistic: StackOverflowError from deeply nested LDValue data, OutOfMemoryError growing the payload stream) makes the executor cancel the task with nothing logged, and enableOrDisableTask returns the dead-but-non-null future forever, so no state transition revives it. DefaultEventProcessor installed an uncaught-exception handler that logged loudly and shut down in an orderly way; there's no counterpart here. Suggest catch (Throwable) in guarded() plus replacing a future whose isDone() is true. Failing regression test available (ProofFlushTaskSurvivesErrorTest).

  3. close() blocking and teardown — this is #281's failure mode, inherited and extended. delivery.get() is unbounded, and because all sends now share the single scheduler thread, close's delivery can wait FIFO behind an in-flight send and a queued diagnostic POST: ~3 failed sends ≈ 2 minutes at default timeouts, versus ~82 s for the old close (which never waited on diagnostics). Issue #281 is exactly this ANR shape on the current release line (main thread parked in postMessageAndWait under LDClient.close()). Separately, scheduler.shutdown() → eventSender.close() doesn't quiesce the executor: shutdown() lets queued tasks run, so a delivery enqueued by a racing setOffline(false) (its submit doesn't re-check closed), or a still-running delivery abandoned by an interrupted wait, executes against the closed sender. Suggested as one change: bounded get(timeout) with a logged give-up, shutdownNow() + bounded awaitTermination() before eventSender.close(), and a closed re-check before the setOffline catch-up submit. (Note: timed get everywhere — untimed get on a task discarded by shutdownNow() hangs forever.) With tier 3 durability this wait stops being load-bearing entirely; bounding it now would let this work close out #281 rather than carry it forward.

Strongly recommended

  1. Serialization runs inside the monitor every evaluation contends on. drain() holds the buffer lock across the full JSON encode (logger.warn on the drop path is under it too), so recording threads — often the main thread — block behind a flush's encoding: measured ~22 ms cold for 100 events, and the work scales with accumulated summary contexts, not capacity. Fix carefully: the wide lock is what makes the failure path atomic today (restoreTo replaces rather than merges), so snapshot-under-lock/encode-outside needs a merging restore upstream first.

  2. capacity no longer bounds event-subsystem memory. The per-context summarizer is unbounded (one entry per distinct context, full attributes retained) and grows from construction while initiallyOffline. Pre-existing in degree, but the inbox backpressure that used to shed bursts is gone, and finding 1 turns "bounded by outage duration" into "bounded by process lifetime." Cap it (counting overflow into droppedEventCount) or state explicitly that a later tier owns it.

  3. An evaluation's summary/full/debug writes are three separate lock acquisitions. A flush can interleave between them (summary in payload N, feature event in N+1), and a close() interleaving can deliver the summary while silently stranding the feature event. One buffer.record(event, requireFullEvent, debugEvent) call restores the atomicity the old single-dispatcher design had, and cuts hot-path lock traffic 3×.

  4. A diagnostics period can be destroyed in exactly the way the comment says it can't. sendDiagnosticStats checks suspension, then destructively reads (getAndClearDroppedCount, createEventAndReset), then sendDiagnosticEvent re-checks suspension and can drop the built event — an offline/background transition in that window discards the period's stats and skews dataSinceDate. Drop the inner re-check for the stats path or re-inject on suppression.

  5. capacity(0) now silently drops every full-fidelity event. The >= 0 ? capacity : 1 normalization is copied from EventsConfiguration (whose javadoc says zero-or-negative → 1), but on main new ArrayBlockingQueue<>(0) crashed init loudly, masking the off-by-one; that mask is gone with the inbox. Use > 0, and consider a builder-level floor.

  6. Test gaps sit in precisely the new risk areas. diagnosticInitEventIsSentWhenComingOnline's only-one-init assertion never exercises the guard (the fixture makes setInBackground(false) a no-op transition); diagnosticInitEventIsNotSentWhileOffline waits for the init to be delivered before going offline; no test flushes concurrently with recording (the one race this design introduces, and the regression test a future lock-narrowing needs); and the injectable constructor is never used, leaving 401/mustShutDown, dropped-count-to-diagnostics accounting, and periodic stats uncovered. The two Proof* tests above are written as regression tests (they pass once 1 and 2 are fixed) and are available to include.

Worth stating what's good here too: privacy redaction is byte-for-byte parity with genuinely thorough new tests; capacity accounting, sampling, debug-event TTL, and wire ordering all verified equivalent; close() fixes a scheduler-thread leak the old wiring had on every init/close cycle; and blockingFlush() now actually matches its documented semantics.

* {@code finish()} or backgrounding would run the SDK's background flush, so this uses
* {@link android.os.Process#killProcess}.
*/
private void setupKillUnsentButton() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should be committed. This example isn't meant to be about edge cases we are expected to handle but more about public API usage.

@abelonogov-ld abelonogov-ld Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving into separate test-app

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the event processor on main, the flushing thread that handled events was separate from what handled diagnostics. Now it is possible for a slow diagnostics task to delay the event flushing task. This shouldn't be possible as diagnostics is a secondary concern to event reporting throughput. May just be worth mirroring the separation on main to maintain parity.

@abelonogov-ld abelonogov-ld Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnostic Events now use independent executor as in Java Server

// Set when the service tells us to stop, e.g. because the mobile key is invalid.
private final AtomicBoolean disabled = new AtomicBoolean(false);
private final AtomicBoolean diagnosticInitSent = new AtomicBoolean(false);
private final AtomicLong lastKnownPastTime = new AtomicLong(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like only closed actually needs to be an atomic boolean? Doesn't hurt / cost too much for them to be, just pointing it out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastKnownPastTime will matter in tier-3 other 2 fixed

Future<?> delivery = submit(this::deliverPayload);
if (delivery != null) {
try {
delivery.get();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close() can block ~50% longer than the wait that's already generating field ANRs (#281) — suggest bounding it

close() waits on delivery.get() with no timeout (DirectEventProcessor.java:201). Since the single-threaded scheduler runs every send FIFO, that wait covers, worst case: the currently-running send (cancel(false) doesn't stop a body already executing — and that body can now be a diagnostic POST, not just analytics), any queued diagnostic-init send from a recent online/foreground transition, and then the final delivery itself. At default config each failed send costs ~41 s (2 attempts × (10 s connect + ~10 s read) + 1 s retry in DefaultEventSender), so the stacked worst case is ~2 minutes, against ~82 s for the code this replaces — DefaultEventProcessor.close() waited only on analytics flush workers (diagnostics ran on sharedExecutor and were never on the close path), and its 1-slot payload queue capped the backlog at two sends. Even the mildest single-timeout case (~10 s) exceeds the ANR threshold when close() runs on the main thread.

To be clear about the half this PR gets right: the old close could also return instantly under inbox pressure, silently dropping the final flush — this PR's "the caller is entitled to assume the events made it out" semantics and interruptible wait are genuine improvements. The problem is only that the wait is unbounded and its worst case grew.

This matters beyond theory: #281 (open) is this exact failure on the current release line — main thread parked in postMessageAndWait's semaphore under LDClient.close(), at scale, on Android 14 devices with half-open networks. This PR inherits that failure mode and lengthens the chain by moving diagnostic sends onto the same thread as the close-path delivery.

Suggestions, separable:

  1. delivery.get(timeout, unit) with a logged give-up — a few seconds is a defensible budget for a last-chance delivery from a lifecycle callback.
  2. Keep diagnostic sends from queueing ahead of close's delivery (or cap diagnostics at a single attempt, no retry — they're best-effort telemetry).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both are done

}
}
scheduler.shutdown();
eventSender.close();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think shutdown guarantees other race conditions haven't scheduled work (e.g. a simultaneous thread in setOffline).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is become different after diagnostic separation work

}
// Flushing is pointless while we are offline, but it stays on in the background so that
// events recorded before the app was backgrounded still get delivered.
flushTask = enableOrDisableTask(!offline, flushTask, flushIntervalMillis,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't you want to be willing to disable tasks independent of if the instance is closed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Cancelling no longer depends on the closed check: updateScheduledTasks now uses closed only to decide whether to start work, and close() cancels by calling it under stateLock instead of doing its own cancel calls.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having closed checks throughout many functions of a class that can run in a multithreaded environment is usually an indication of code smell. The closed checks may prevent you from doing work that was not desired, but they don't protect against two threads getting past those closed checks, so you end up needing proper locking anyways. There appear to be a few of these race conditions in this code.

Closed checks can work nicely in a single threaded region or single isolate language though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and I cut them down. Two entry-point checks in setOffline and setInBackground were redundant and are gone

new OutputStreamWriter(buffer, StandardCharsets.UTF_8), INITIAL_OUTPUT_BUFFER_SIZE);
int outputEventCount;
try {
outputEventCount = formatter.writeOutputEvents(eventsOut, summaries, writer);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serialization happening while holding class lock via due to synchronized.

abelonogov-ld and others added 12 commits September 21, 2026 14:47
…/sdk/android/DirectEventProcessor.java

Co-authored-by: Todd Anderson <127344469+tanderson-ld@users.noreply.github.com>
Recording an event and encoding a batch of them were sharing one monitor.
Encoding is the dominant cost on the path and runs on the events executor;
recording runs on whichever thread evaluated a flag, which on Android is
usually the main one. An evaluation could therefore wait out a whole batch
encode before it was allowed to add a counter.

The list of full events, the capacity counted against it and the drop count
move to DirectEventProcessor. A delivery takes the run under pendingLock and
encodes it after releasing, so a recording thread waits only for another
thread's memory operation. OutboundEventBuffer keeps the counters and the wire
format, and takes the summaries under its own lock for the same reason.

A run that cannot be serialized is now discarded rather than put back. Nothing
the encoder can fail on is transient -- it writes to a byte array -- so a
restored run would have failed again on every later flush and no events would
ever have been delivered again.

EventProcessorTestBase now waits out the delivery that coming online schedules.
It was previously free to land in the middle of a test's recording loop and
take a batch the test had not asked to send, which at capacity reads as the
limit having been exceeded.

Co-authored-by: Cursor <cursoragent@cursor.com>
Nothing in it logs any more. The capacity warning went to the processor with
the capacity check, and a failed encode is thrown to the caller rather than
reported here.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* main:
  test(fdv2): stop requiring a changeset to be the first result
Co-authored-by: Cursor <cursoragent@cursor.com>
abelonogov-ld and others added 10 commits September 22, 2026 13:10
disabled and diagnosticInitSent are one-way latches that only ever get
set once and read; neither needs an atomic. Leaving them as AtomicBoolean
obscured the one flag in the class that does need it, closed, whose CAS
is what makes close() idempotent.

lastKnownPastTime stays atomic but now moves forwards only. Analytics and
diagnostic responses are handled on separate threads since diagnostics got
their own executor, so a plain assignment let an older reading of the
service clock overwrite a newer one.

Co-authored-by: Cursor <cursoragent@cursor.com>
The scheduling state machine was already correct: close() sets the flag
before taking stateLock and cancels under it, so whichever side reaches
the lock second sees the other's work. setInBackground and setOffline
were re-testing the flag on the way in, which added nothing; the check
inside updateScheduledTasks is the one that decides, and now says so.

record() tested the flag outside pendingLock, so a record could
interleave with close()'s final delivery and leave an event in a list
nothing would drain again. The test now happens under the lock that
deliverPayload takes to lift the run out, which orders the two.

submit() and close()'s teardown now share submitLock, so a flush cannot
land between the sender release being queued and the executors being
shut down, where it would have been queued behind the release.

Co-authored-by: Cursor <cursoragent@cursor.com>
The per-context summarizer keeps a counter set per context, keyed on the
whole context with every attribute retained, and capacity did not reach
it: 5,000 offline identify-and-evaluate cycles retained 5.3 MB with
capacity set to 100. Nothing drains it while the client is offline, which
is exactly when an application is free to go on evaluating, so the only
bound was how long the outage lasted.

Capacity now bounds the cardinality as well. Only a context that is not
already being counted can be turned away, so an application evaluating
against one context pays nothing for the limit however many evaluations
it does, and a delivery makes room for the next set. A refused evaluation
is counted into the dropped total diagnostics carry, because nothing
later reconstructs a counter.

Co-authored-by: Cursor <cursoragent@cursor.com>
One helper for both, so a reset path that cleared the counters alone
cannot spend the cardinality limit on contexts whose counters have
already gone out.

Co-authored-by: Cursor <cursoragent@cursor.com>
An evaluation of a tracked flag writes a summary counter and a feature
event, and a debug event alongside them when debugging is on. Those
writes took separate locks, so a close() landing between them delivered
the counter and then refused the event, reporting an evaluation that no
event describes.

Widen the lock that guarded the pending events to cover the summary
counters too, so the writes one evaluation makes are one critical
section. drain() splits into takeSummaries() and encode() so the flush
can take the run and the counters together and still encode outside the
lock.

Co-authored-by: Cursor <cursoragent@cursor.com>
createEventAndReset hands back a period's statistics and clears them in
the same call, so whoever builds the event owns it. It was built on the
scheduler thread and posted from the diagnostics thread, which may have
been busy with an earlier post; a transition to offline or background in
that window was noticed after the build and discarded the event. The
period's stream inits, events-in-batch count and dropped count went with
it, and dataSinceDate had already moved, so the next event described a
window beginning after the reset.

Move the build onto the posting thread, behind the suspension check, so
a transition leaves the counters where they are for the next period to
carry. The init event keeps its check, having nothing to consume.

Co-authored-by: Cursor <cursoragent@cursor.com>
The normalization said `>= 0`, so a capacity of nought stayed nought. On main
that threw from `new ArrayBlockingQueue<>(0)` at construction; with the pending
list there is nothing to throw, so it became a silent refusal of every event.

Worse than it sounds, because the same number is the buffer's context limit and
only the processor was normalizing it: at nought neither full events nor
summaries survived, and at -5 the processor allowed one event while the buffer
refused to summarize at all. So the fix is one normalization in ComponentsImpl
feeding both, with the processor keeping its own guard for direct construction.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ing the server

describeConfiguration was sending the raw capacity, so a client configured with
nought told the service nought while running at one. It now shares the one
normalization with the buffer and the processor.

The race test was flaking about one run in six. Not the processor: the test
server records a request body with a single unlooped read, so a body past one
socket read's worth comes back truncated, at a point that moves between runs.
Thirty events instead of a hundred and fifty stays clear of it, and the test
still fails on every un-fixed run.

Co-authored-by: Cursor <cursoragent@cursor.com>
Three components bound themselves by this number and each was normalizing it,
or not. Clamping in the setter makes the field valid by construction, which is
what the sibling setter for the diagnostic interval already does, and leaves
every consumer -- the buffer, the processor, the diagnostic description -- to
just use it.

Co-authored-by: Cursor <cursoragent@cursor.com>
The two init tests asserted nothing. One asked a foreground processor to go to
the foreground, which setInBackground returns from before reaching any decision;
the other waited for the init event before going offline, so the only thing
stopping a second one was that the first had already gone. Each was held up by
the guard the other claimed to cover, and both passed with every guard deleted.

Coming to the foreground now goes through the background first, and waits for
the diagnostics thread to come free -- while the first init is still posting,
the claim is taken and the transition is turned away before it decides anything.
The offline one builds a processor that never comes online, then brings it
online to show the silence was the offline state and not a dead fixture.

Two new ones. A must-shut-down response has to stop recording and delivery for
good, which nothing exercised. And a flush running against four recorders has to
keep each evaluation in one payload: close() delivers once, so it can catch an
evaluation being stranded but never one being split across two deliveries.
Eight trials, because a single one catches a split lock about two times in three.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 38337ab. Configure here.

abelonogov-ld and others added 4 commits September 22, 2026 17:24
The MalformedJsonException in aContextAlreadyBeingCountedKeepsCountingOnceTheLimitIsReached
is not a serialization race: recording is sequential on the test thread and the payload is a
deterministic ~28 KB. The test-helpers HTTP server reads request bodies with a single unlooped
read, so larger bodies can be truncated depending on the machine. Fill the summarizer to a
limit of 10 contexts instead of 100 so each flush stays well under that size.

Co-authored-by: Cursor <cursoragent@cursor.com>
Offline means the application told the SDK to stay off the network, and closing does not revoke
that, so the final delivery in close() is skipped and held events are dropped. DefaultEventProcessor
behaved the same way (its FLUSH is a no-op while offline); the comments and the EventProcessor
javadoc promised more than either implementation did. Add a test pinning the behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
…n reconnect

The SDK builds the event processor offline and turns it on once initialization is done. Coming online
used to deliver immediately, meant for the end of an outage, but it also fired on that first transition
at startup and sent the initial identify alone before anything else was recorded. DefaultEventProcessor
never did that, and the contract tests expect the first payload to wait for a flush.

The immediate delivery existed only because cancelling the periodic flush for an outage restarted its
interval on every reconnect. The flush task now stays scheduled regardless of the offline state (a run
while offline does nothing), so the interval is never restarted and what an outage buffered goes out at
the first run after it ends. No transition needs special handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
updateScheduledTasks no longer returns early once the processor is
closed. The closed flag now gates only starting work, so cancelling
always happens, and close() cancels its tasks by calling
updateScheduledTasks under stateLock instead of repeating the cancel
calls itself.

Co-authored-by: Cursor <cursoragent@cursor.com>
abelonogov-ld and others added 2 commits September 22, 2026 22:39
- Wrap recordEvaluationEvent, recordIdentifyEvent, recordCustomEvent, and
  record in try/catch to ensure unexpected telemetry failures never crash
  user application calls.
- Reset summaryContextsExceeded when taking summaries during a flush run so
  subsequent runs that exceed the context limit log the warning.
- Add unit tests confirming both behaviors.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Catch RuntimeException rather than Throwable on the recording path.
  It runs on the application's thread, so an Error such as
  OutOfMemoryError now propagates to the application's crash reporting
  instead of being logged and swallowed.
- Drop the outer try in recordIdentifyEvent and recordCustomEvent;
  record() already handles its own failures.
- Claim the context-limit warning under recordLock, the lock the
  delivery resets it under. Claimed outside it, a delivery landing in
  between let one run's overflow suppress the next run's warning.
- Add a test that an Error while recording reaches the caller.

Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -88,64 +126,190 @@ synchronized void summarize(Event.FeatureRequest event) {
event.getDefaultVal(),
event.getContext()
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just now realizing the context hash done by !countedContexts.contains(event.getContext()) and by summarizeEvent for equality is not insignificant when compared to serialization of events. This PR is the first time LDContext hashing is being done on the hotpath.

Based on my local benchmarking, for the canonical single-context Android app, hashing overtakes serialization at 5.3 evaluations per 30-second window — one eval every ~5.6 seconds. Any app that evaluates a flag even once per screen render is thousands of times past the crossover where context equality hashing dominates CPU.

Have you investigated / measured this component of CPU usage?

* contexts are counted at once, because each one costs a retained context and its own counters.
*
* @param event the evaluation
* @return false if this evaluation was not counted, because counting it would have meant holding

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should read false if this event was not counted

This branch has not been deployed

No deployments
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.

jdk.internal.misc.Unsafe.park

2 participants