Repository navigation
feat(events): persist events to disk so they survive the process (tier 3) - #408
Open
abelonogov-ld wants to merge 307 commits into
Open
abelonogov-ld wants to merge 307 commits into
abelonogov-ld wants to merge 307 commits into
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
…-durability-tier2-bounded-flush
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java
Co-authored-by: Cursor <cursoragent@cursor.com>
* main: test(fdv2): stop requiring a changeset to be the first result
…-durability-tier2-bounded-flush * andrey/event-durability-tier1-buffer: test(fdv2): stop requiring a changeset to be the first result
…y/event-durability-tier3-persistence * andrey/event-durability-tier2-bounded-flush: test(fdv2): stop requiring a changeset to be the first result
Co-authored-by: Cursor <cursoragent@cursor.com>
…-durability-tier2-bounded-flush
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java # launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DirectEventProcessorTest.java
At this tier analytics go through AnalyticsEventSender, so the other sender only ever posts diagnostics. Naming it eventSender next to analyticsEventSender read as if it were the general-purpose one. Co-authored-by: Cursor <cursoragent@cursor.com>
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>
…-durability-tier2-bounded-flush Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java
…y/event-durability-tier3-persistence 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>
…-durability-tier2-bounded-flush Co-authored-by: Cursor <cursoragent@cursor.com>
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java
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>
…-durability-tier2-bounded-flush
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java # launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DirectEventProcessorTest.java
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>
…-durability-tier2-bounded-flush
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/OutboundEventBuffer.java
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>
…-durability-tier2-bounded-flush Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/OutboundEventBuffer.java
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>
…-durability-tier2-bounded-flush
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DirectEventProcessorTest.java
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>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A delivery sends the oldest batches first, which are what a previous run left, so a flush interval shorter than the launch delay sent them ahead of the gate. Until it opens, the periodic flush only commits. Co-authored-by: Cursor <cursoragent@cursor.com>
flushAndWait is the last chance a crashing or backgrounded app gets, and after a refusal the backoff allowed nothing for minutes to an hour, so it made no attempt. LDClient.flushAndWait now asks DirectEventProcessor for a last-chance flush, which posts through the backoff as close() does and is bounded by the caller's timeout. flush(), flushAsync() and the periodic delivery still wait it out. A last-chance request that joins a queued ordinary one upgrades it. Co-authored-by: Cursor <cursoragent@cursor.com>
Name the flush, and the flags that carry it through the scheduler, for what they do rather than for who calls them. Co-authored-by: Cursor <cursoragent@cursor.com>
Data nested deeply enough made the encoder throw StackOverflowError, which the per-event isolation did not catch. It escaped the commit after the run and the summary counters had left pending, so every event since the last commit was lost uncounted, the next flush answered true, and at IMMEDIATE the error came out of the application's track(). The encoder now drops just that event and counts it, and anything else escaping the commit's encoding is counted as a loss. Co-authored-by: Cursor <cursoragent@cursor.com>
Each delivery that took the queued retry as its answer bridged the retry's ScheduledFuture with LDFutures.fromFuture, which parks a thread from an unbounded pool until the retry runs: one thread per flush called in the second after a failure, and for as long as a stored batch keeps failing to read. The retry now completes an LDAwaitFuture itself, and its waiters are answered from that. Co-authored-by: Cursor <cursoragent@cursor.com>
…as it was countBadRequest ended the run for any status that was not a refusal of the payload, including 0, which is what a network or TLS failure reports. So a batch the service will never take, refused with failures in between, never reached two refusals in a row, and stayed the oldest, holding back every batch behind it; in memory nothing expires it. A failure with no response now leaves the count unchanged, and any response other than a refusal still ends the run. Co-authored-by: Cursor <cursoragent@cursor.com>
After the first retry, flush() and the periodic delivery post nothing while the backoff waits; only flushAndWait and close post through it. Co-authored-by: Cursor <cursoragent@cursor.com>
Capacity counts events, so in memory through an outage it allowed that many of any size. The byte capacity now bounds full events whatever eventPersistence is, checked in the same critical section as the count, as on iOS. Co-authored-by: Cursor <cursoragent@cursor.com>
…lost A close that found the open log gone zeroed its events with no loss counted, so flushAndWait could answer true for events that no longer existed (ATR3-003 item 1, C2). Where the store still held the log locked nothing could have recovered it, so its events are now counted as lost before the next loss mark, as on iOS. A log already given up on and unlocked may have been delivered by a recovery, and is not counted. Co-authored-by: Cursor <cursoragent@cursor.com>
… than deleting it unnoticed A batch named without a count, in a format this version does not read, was deleted by the listing with no loss counted (ATR3-003 item 2). It is now listed as holding one event, the fewest it can, as one too large to read already is, so the delivery that reaches it drops it and counts the loss. Co-authored-by: Cursor <cursoragent@cursor.com>
…nt lost, not none Its frames count as no events, so the delivery that dropped it as unreadable added zero to the loss count and no flush heard of it (ATR3-003 item 3). The listing now counts it as holding one event, the fewest it can. Co-authored-by: Cursor <cursoragent@cursor.com>
… in a row A read that failed kept the batch and stopped the pass at it, so every batch behind it, the open log's included, waited for as long as the read kept failing: for good where expiry cannot open the file either (N1). The pass now goes on past it, reading it once a pass, and a third failed read in a row drops it and counts its events as lost. Co-authored-by: Cursor <cursoragent@cursor.com>
Expiry and sending in one environment no longer wait on those of another. The monitors are process-wide by directory, so stores sharing a directory still share one. Co-authored-by: Cursor <cursoragent@cursor.com>
A context's summary over a frame is halved by flag, again as needed, into summaries of the same context and period. LaunchDarkly sums them, so nothing is lost; only one flag's counters too large for a frame are left whole for the store to refuse. Co-authored-by: Cursor <cursoragent@cursor.com>
…010) At DEFERRED a track or identify now writes nothing itself, as durability 13.3 says. Its events are committed with the rest of what is held at the pending threshold, a flush, a delivery, backgrounding or close. A commit per call cost an application that tracks every few seconds one encode and write per event, each contending with flag evaluations for the record lock and with delivery for the store's locks. IMMEDIATE still commits on the caller's thread. Co-authored-by: Cursor <cursoragent@cursor.com>
Vadman97
approved these changes
Oct 30, 2026
Co-authored-by: Cursor <cursoragent@cursor.com>
Contributor
Author
|
bugbot run |
ConnectivityManager flushes on every move to the background, and the flush commits on that thread wherever events persist. setInBackground only reschedules the processor's tasks, which made the backgrounding write look missing. Co-authored-by: Cursor <cursoragent@cursor.com>
flushAndWait and close() post through the backoff. A failure there used to step it: four refused calls during a 401 took the wait from about 8.5 minutes to an hour, holding flush, the periodic delivery and the retry with it. Now such a failure keeps the attempt count and the ceiling, and a Retry-After can only lengthen the wait. flushAndWait's javadoc says it is for last chances only and drops the move to the background as a use. Co-authored-by: Cursor <cursoragent@cursor.com>
A batch was dropped at its third failed read in a row, counted per pass. Passes can come milliseconds apart -- flushes in a row, or flushAndWait posting through the backoff -- so a failure lasting a moment deleted an intact batch. Now a failed read counts only at least a flush interval after the last counted one, and a read that ran short of memory, which says nothing about the file, never counts. Co-authored-by: Cursor <cursoragent@cursor.com>
Every part of a split summary carries the whole context, so with a context near a frame's size the halving went on down to one flag per part: one loss per flag where there was one, each holding the context. A half that kept more than three quarters of what it was split from is now left whole. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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 d9e4be5. Configure here.
Contributor
|
Approved, will approve officially on Monday. |
The three-quarter guard on halving stopped at any half that was mostly context, even where one more split would have fit, so those evaluations were refused. Now each flag's size is measured with a small stand-in context, and the flags are packed in key order into as few parts as fit a frame beside the context. A context that alone takes a frame still leaves the summary whole, one loss; halving remains only for a part that comes out larger than measured. Co-authored-by: Cursor <cursoragent@cursor.com>
This branch has not been deployed
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.

Requirements
Related issues
Tier 3 of the Event Durability spec, "Tier 3 — persistence". Stacked on #403 (tier 2) and targets that branch, so this diff shows only tier 3. It will be retargeted once the branches below it merge. The iOS counterpart is launchdarkly/ios-client-sdk#538, and both write the same on-disk format.
Opened as a draft: a multi-threading and readability review turned up items still being worked through (see Known open items).
Describe the solution you've provided
Recorded events go to an append-only log on disk rather than only an in-memory buffer, so an application that dies shortly after recording an event still reports it: the next run delivers what the previous one left behind. That covers what tier 2 cannot:
SIGKILL, ANR kills, native crashes, and the system reclaiming a backgrounded process.EventPersistence(new public enum) andEventProcessorBuilder.eventPersistence(...):DISABLED(default): events stay in memory and the SDK does not touch the filesystem for events.DEFERRED: writes happen on the SDK's commit thread, a moment after recording.IMMEDIATE:trackandidentifywrite on the caller's thread before returning, so there is no window.EventStore: an append-only log under the no-backup files directory, one directory per mobile key. It handles framing, capacity, torn-tail recovery of a log interrupted by a crash, and a fallback to memory when a write fails (for example, a full disk), so a disk failure never takes the application down. Each process appends to its ownopen-<process>log; closedready-<payloadId>batches are shared, so a batch left by a process that never runs again is still delivered. The byte format lives inEventLogFormatand the log this process appends to inDiskEventLog, mirroring the iOS split.DirectEventProcessor): an evaluation appends to a pending run and updates a counter, with no encoding on the caller's thread. A commit encodes the run and its summaries together and stages them in the store.trackandidentifyare commit points. Evaluations commit once a threshold accumulates, on flush, and before delivery.flushAsync()answersfalsewhen events the SDK accepted were lost for good: an event or summary that could not be serialized, or that was too large to store. Each loss is reported to exactly one caller, the one whose delivery closed its batch after the loss, as on iOS. Events dropped for capacity are counted in diagnostics, not reported as losses.close()commits first, then delivers with what is left of one two-second budget. Neither step waits on the caller's thread past the budget, so a stalled disk write cannot become an ANR. Work that outlasts the budget finishes in the background.AnalyticsEventSenderposts a pre-assembled body under an explicit payload ID.DEFAULT_CAPACITYgoes from 100 to 1000, matching iOS, since capacity now also bounds what is held on disk.Describe alternatives you've considered
SQLite. Measured as a store and rejected: it costs more per event than an append to a log the SDK already frames, for durability properties the log gets from
writealone. Seesqlite-event-persistence-findings.mdin the spec folder.Fsync on every commit. What durability is defending against is the process dying, not the device losing power. Bytes handed to the kernel survive the former, and an fsync per event would dominate the cost of
track.One shared log for all processes. That is simpler, but one process's startup recovery could rename a log another live process is still appending to. A log per process avoids that without file locks.
Additional context
Tests. 879 unit tests pass locally (
testDebugUnitTest). New coverage includesEventStoreMultiProcessTest(several stores on one directory, recovery, transient read failures, events still counted against capacity while being written),DirectEventProcessorTest(commit points, retry, capacity across memory and disk, persistence failing partway through, oversized frames reported as losses, a loss reported to the right flush across a retry,close()within budget on a stalled disk write),OutboundEventBufferSerializationTest, andSameContextPerContextEventSummarizerTest.Benchmarks.
EventPersistenceBenchmark(JVM and instrumented) measures recording cost per mode. It runs only whenLD_EVENT_BENCH=1, so ordinary test runs are unaffected.Test app. Now configures
EventPersistence.IMMEDIATE, so the events recorded by Eval+Kill now, which tier 2 loses, are recovered and delivered on the next launch. It also gains Over-Refresh Eval, a main-thread stress loop that compares evaluation cost while filling capacity and after it is full.Known open items (from the threading review, to be resolved before this leaves draft):
flushAsync(), and soflushAndWait, does not commit on the caller's thread asflush()does, so aflushAndWaitthat times out can leave events only in memory, even atIMMEDIATE.Version.
EventPersistenceandEventProcessorBuilder.eventPersistencehave no@sincetag yet; one should be added once the release version is known.Note
Overview
Tier 3 event durability replaces the wrapped
java-sdk-internalDefaultEventProcessorwith an Android-specific pipeline:DirectEventProcessor,EventCommitter, on-diskEventStore/DiskEventLog,BatchLocksfor multi-process sends,AnalyticsEventSender(stableX-LaunchDarkly-Payload-IDper batch),DeliveryRetryState, and a splitDiagnosticReporter.Apps opt in via new
EventPersistence(DISABLEDdefault,DEFERRED,IMMEDIATE) onEventProcessorBuilder.IMMEDIATEmakestrack/identifycommit on the caller thread; persisted runs recover interrupted logs at launch, pace delivery so flag traffic goes first, retry with spec backoff, andflushAsync()reportsfalsewhen accepted events were lost (not capacity drops).close()commits then delivers within a short budget without blocking past ANR limits.LDClient.flushAndWait(plusLDCrashHandler) flushes all environments in parallel, bypasses backoff where needed, and is covered by new instrumented tests; the example app switches toLDClient.init(..., INIT_WAIT_SECONDS)instead of blocking on aFuture.Also bumps
launchdarkly-java-sdk-internalto 1.12.2, adds optionalEventPersistenceBenchmark/BatchLocksAndroidTest, Gradle hooks whenLD_EVENT_BENCH=1, and minor build/gitignore cleanup.Reviewed by Cursor Bugbot for commit 57d3ebb. Bugbot is set up for automated code reviews on this repo. Configure here.