Skip to content

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

Open
abelonogov-ld wants to merge 308 commits into
mainfrom
andrey/event-durability-tier3-persistence
Open

abelonogov-ld wants to merge 308 commits into
mainfrom
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 wrapped DefaultEventProcessor with a new DirectEventProcessor stack that can append events to on-disk logs, deliver ready-* batches with stable payload IDs, and survive process death.

Apps opt in via EventPersistence on EventProcessorBuilder (DISABLED default, DEFERRED commit thread, IMMEDIATE synchronous commit on track/identify). Storage lives under getNoBackupFilesDir(), with EventStore, DiskEventLog, BatchLocks, and a dedicated AnalyticsEventSender that sends caller-chosen payload IDs. Delivery adds retry/backoff, launch pacing so flag fetches win at startup, flushAsync() / flushAndWait() (plus LDCrashHandler tests), and close() behavior that commits then posts within a short budget.

Supporting changes: bump launchdarkly-java-sdk-internal to 1.12.2, default event capacity 1000, example LDClient.init(..., waitSeconds) instead of Future.get, optional LD_EVENT_BENCH device benchmark wiring, BatchLocksAndroidTest, and a test-app module entry in Gradle settings.

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

abelonogov-ld and others added 30 commits September 22, 2026 12:14
…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>
…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>
…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>
…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>
…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>
…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>
abelonogov-ld and others added 2 commits October 28, 2026 13:12
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
abelonogov-ld and others added 15 commits October 28, 2026 16:05
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>

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

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

@tanderson-ld

Copy link
Copy Markdown
Contributor

Approved, will approve officially on Monday.

abelonogov-ld and others added 2 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>

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