Skip to content

feat(events): persist events to disk so they survive the process (tier 3) - #408

Open
abelonogov-ld wants to merge 223 commits into
andrey/event-durability-tier2-bounded-flushfrom
andrey/event-durability-tier3-persistence
Open

abelonogov-ld wants to merge 223 commits into
andrey/event-durability-tier2-bounded-flushfrom
andrey/event-durability-tier3-persistence

Conversation

@abelonogov-ld

@abelonogov-ld abelonogov-ld commented Oct 9, 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

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) and EventProcessorBuilder.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: track and identify write 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 own open-<process> log; closed ready-<payloadId> batches are shared, so a batch left by a process that never runs again is still delivered. The byte format lives in EventLogFormat and the log this process appends to in DiskEventLog, mirroring the iOS split.
  • Recording and commits (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. track and identify are commit points. Evaluations commit once a threshold accumulates, on flush, and before delivery.
  • Delivery reads batches oldest first. A batch keeps its payload ID across retries and process restarts, so LaunchDarkly discards a repeated delivery instead of double counting. Delivery stops at the first retryable failure, schedules a single retry instead of sleeping on the events thread, and removes a batch only once it has been accepted or permanently refused. A batch whose read fails transiently is kept for a later attempt.
  • Loss reporting: flushAsync() answers false when 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.
  • AnalyticsEventSender posts a pre-assembled body under an explicit payload ID.
  • DEFAULT_CAPACITY goes 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 write alone. See sqlite-event-persistence-findings.md in 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 includes EventStoreMultiProcessTest (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, and SameContextPerContextEventSummarizerTest.

Benchmarks. EventPersistenceBenchmark (JVM and instrumented) measures recording cost per mode. It runs only when LD_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 so flushAndWait, does not commit on the caller's thread as flush() does, so a flushAndWait that times out can leave events only in memory, even at IMMEDIATE.
  • Batches left by a previous run are not counted against capacity until the first listing, which an offline start postpones.
  • The store and the processor both schedule commits, so a threshold commit is followed by a redundant no-op pass.

Version. EventPersistence and EventProcessorBuilder.eventPersistence have no @since tag yet; one should be added once the release version is known.


Note

Overview
Tier 3 event durability replaces the java-sdk-internal DefaultEventProcessor wrapper with an Android DirectEventProcessor that commits framed events to an on-disk EventStore (iOS-compatible EventLogFormat, per-process append logs, ready-* batches, BatchLocks for multi-process send/expiry). Apps opt in via public EventPersistence (DISABLED / DEFERRED / IMMEDIATE) on EventProcessorBuilder, with durable data under getNoBackupFilesDir().

Delivery is reworked around AnalyticsEventSender (stable X-LaunchDarkly-Payload-ID), DeliveryRetryState, paced/launch-gated sends, and flushAsync() / flushAndWait() semantics—including parallel multi-environment flush and LDCrashHandler flushing before delegating crashes. close() prioritizes commit then a bounded final delivery; losses and payload refusals are tracked so flush callers can get false when accepted events were dropped.

Supporting changes: DiagnosticReporter split out, launchdarkly-java-sdk-internal bumped to 1.12.2, example LDClient.init(..., waitSeconds) instead of Future.get, optional EventPersistenceBenchmark when LD_EVENT_BENCH=1, dexcount removed from the example app, and broad new JVM/android tests (including BatchLocksAndroidTest).

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

abelonogov-ld and others added 30 commits September 9, 2026 11:59
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

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

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

* 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
abelonogov-ld and others added 2 commits October 29, 2026 23:16
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>

@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.

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

Copy link
Copy Markdown
Contributor Author

bugbot run

abelonogov-ld and others added 4 commits October 30, 2026 12:26
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>

@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.

@tanderson-ld

Copy link
Copy Markdown
Contributor

Approved, will approve officially on Monday.

abelonogov-ld and others added 4 commits October 30, 2026 16:43
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>

@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.

… 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,

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

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.

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>
@tanderson-ld
tanderson-ld self-requested a review November 2, 2026 20:37
…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>
@tanderson-ld
tanderson-ld self-requested a review November 3, 2026 17:20
…rsistence again"

This reverts commit a65c588, which moves to a
PR of its own.

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 db668b0. Configure here.

scheduleCommit();
} catch (RuntimeException e) {
// As in recordEvaluation: on the caller's thread, so a failure is logged, not thrown.
logUnexpectedError(e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit db668b0. Configure here.

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.

Fixed in #419

@abelonogov-ld
abelonogov-ld changed the base branch from main to andrey/event-durability-tier2-bounded-flush November 3, 2026 21:08

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.

3 participants