Repository navigation
feat(events): persist events to disk so they survive the process (tier 3) - #408
abelonogov-ld wants to merge 223 commits into
Conversation
Tier 3 of the event durability spec: unrecoverable failure. EventStore stages serialized events in memory and commits them to an append-only log in the no-backup files directory, so events survive a process that never gets to flush. Commit points — track, identify, an explicit flush — write on the caller's thread before returning, and the evaluations counted since the last one are summarized and written with them. Delivery reads closed batches back from disk and deletes them only once LaunchDarkly has accepted them, under a payload ID that stays the same across retries so a redelivered batch is not counted twice. Each process of a multi-process application gets its own log. Spec: Event Durability, "Tier 3 — unrecoverable failure"; the mechanism is Page-Cache Event Persistence. Co-authored-by: Cursor <cursoragent@cursor.com>
The counterpart of the iOS EventPersistenceBenchmark, covering the same five context shapes and three privacy settings so the two platforms can be compared rather than assumed alike. Gated on LD_EVENT_BENCH=1, which also turns on stdout and disables up-to-date checks for the test task, so an ordinary run skips all four and is otherwise unaffected. The answer is that Android does not have the problem iOS had. Its unoptimized path beats iOS's fully optimized one on every row: evaluation, summary only 1.87 us (iOS) -> 68 ns evaluation, full event 17.35 us -> 1.51 us evaluation + track 38.40 us -> 4.92 us Cost still tracks attributes written rather than the redaction decision, as on iOS, but at 48-59 ns per attribute against Codable's 1.14 us. So there is no Codable-shaped overhead to remove, because there is no Codable. Co-authored-by: Cursor <cursoragent@cursor.com>
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com>
…y/event-durability-tier3-persistence
…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
…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
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # example/src/main/java/com/launchdarkly/example/MainActivity.java
Coming online now delivers, and the fixture brings the processor online as it builds it, so the queued delivery could drain the store before the test read it. The commit these cover runs offline just the same. Co-authored-by: Cursor <cursoragent@cursor.com>
…y/event-durability-tier3-persistence
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java # 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
Moving these back to where tier 2 has them leaves the capacity fields and their assignment as unchanged lines, so this branch's diff shows the commit machinery it adds rather than a reshuffle of what it inherited. Co-authored-by: Cursor <cursoragent@cursor.com>
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # example/src/main/java/com/launchdarkly/example/MainActivity.java # example/src/main/res/layout/activity_main.xml
…y/event-durability-tier3-persistence
…y/event-durability-tier3-persistence * andrey/event-durability-tier2-bounded-flush: unserializable unit test
…y/event-durability-tier3-persistence Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java # 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
…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>
…y/event-durability-tier3-persistence * andrey/event-durability-tier2-bounded-flush: test(fdv2): stop requiring a changeset to be the first result
…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>
…y/event-durability-tier3-persistence 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
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>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
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>
|
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>
…ler's thread Co-authored-by: Cursor <cursoragent@cursor.com>
…rites everything before it returns Co-authored-by: Cursor <cursoragent@cursor.com>
…ence The caller's thread encodes and writes the one event, without waiting for a commit in progress; the held evaluations and the summaries are left to a commit queued on the commit executor in the same call. The summarizer's counters are still taken at each track or identify, so its contexts are cleared as before. Co-authored-by: Cursor <cursoragent@cursor.com>
… reserved place A track or identify at immediate persistence committed the store, which wrote whatever a commit in flight had staged so far, up to megabytes of a run or a split summary, on the application thread. It now writes just its own frame and leaves the rest to the commit that staged it. It is also reserved where it is admitted, so a recording on another thread cannot take its place while it is encoded and have it refused as a loss. A commit now releases only its own unused reservations rather than all of them. Co-authored-by: Cursor <cursoragent@cursor.com>
| * @param size the whole summary's encoded size | ||
| * @return the flags of each part | ||
| */ | ||
| private List<List<String>> packIntoFrames(EventSummarizer.EventSummary summary, List<String> flagKeys, |
There was a problem hiding this comment.
This is way too much complexity for the probability of this large context summary problem happening. We would rather just allow this to exceed the limit (apparently the customer really wants to use a large context) OR we drop such data and log a warning. We don't have precedent for dropping a large context, so we'd have to think a bit on it.
There was a problem hiding this comment.
Removed. Chances of this happening are pretty low. You need many events of large individually. It is fine to drop packet in 1 in million cases
A summary too large for one log frame is refused whole by the store, logged, and counted as lost, so the flush that committed it answers false. Reaching that takes one context evaluating dozens of the largest JSON flags between two commits, which did not justify the packing and halving that split it. Co-authored-by: Cursor <cursoragent@cursor.com>
…e again Writing only the tracked event left the evaluations before it to a commit queued on the commit executor, so a kill straight after the track, which is what immediate persistence is for, could take those exposures with it. With the summary split gone, the commit costs the caller a normal encode of the held run and the summaries, so the track commits it again before returning. This reverts 19afcec and 1200025. Co-authored-by: Cursor <cursoragent@cursor.com>
…rsistence again" This reverts commit a65c588, which moves to a PR of its own. 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 db668b0. Configure here.
| scheduleCommit(); | ||
| } catch (RuntimeException e) { | ||
| // As in recordEvaluation: on the caller's thread, so a failure is logged, not thrown. | ||
| logUnexpectedError(e); |
There was a problem hiding this comment.
Track can miss close delivery
Medium Severity
At IMMEDIATE, writeAlone admits a track or identify under recordLock but does not put it in pending and writes later without commitLock. close() can set closed, commit an empty run, finish the last delivery, and release the store before that write. The accepted event then lands in a new open log after closeBatch, so this session never sends it. If the write happens after store.close(), it can also reopen a log on an abandoned store.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit db668b0. Configure here.


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
java-sdk-internalDefaultEventProcessorwrapper with an AndroidDirectEventProcessorthat commits framed events to an on-diskEventStore(iOS-compatibleEventLogFormat, per-process append logs,ready-*batches,BatchLocksfor multi-process send/expiry). Apps opt in via publicEventPersistence(DISABLED/DEFERRED/IMMEDIATE) onEventProcessorBuilder, with durable data undergetNoBackupFilesDir().Delivery is reworked around
AnalyticsEventSender(stableX-LaunchDarkly-Payload-ID),DeliveryRetryState, paced/launch-gated sends, andflushAsync()/flushAndWait()semantics—including parallel multi-environment flush andLDCrashHandlerflushing before delegating crashes.close()prioritizes commit then a bounded final delivery; losses and payload refusals are tracked so flush callers can getfalsewhen accepted events were dropped.Supporting changes:
DiagnosticReportersplit out,launchdarkly-java-sdk-internalbumped to 1.12.2, exampleLDClient.init(..., waitSeconds)instead ofFuture.get, optionalEventPersistenceBenchmarkwhenLD_EVENT_BENCH=1, dexcount removed from the example app, and broad new JVM/android tests (includingBatchLocksAndroidTest).Reviewed by Cursor Bugbot for commit db668b0. Bugbot is set up for automated code reviews on this repo. Configure here.