Repository navigation
feat(events): persist events to disk so they survive the process (tier 3) - #408
abelonogov-ld wants to merge 286 commits into
Conversation
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>
…-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
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>
…-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/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>
Co-authored-by: Cursor <cursoragent@cursor.com>
…-durability-tier2-bounded-flush 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 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
Co-authored-by: Cursor <cursoragent@cursor.com>
…-durability-tier2-bounded-flush
…y/event-durability-tier3-persistence
…-durability-tier2-bounded-flush * andrey/event-durability-tier1-buffer: unserializable unit test
…y/event-durability-tier3-persistence * andrey/event-durability-tier2-bounded-flush: unserializable unit test
…-durability-tier2-bounded-flush Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DirectEventProcessorTest.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/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
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
EventDirectory says it decides what the files in the directory are called, but the log's two names, pending- and new-, were DiskEventLog's, which also built them. They are EventDirectory's now, beside ready-, and DiskEventLog takes a new log's name, and the one it is opened under, from there. Co-authored-by: Cursor <cursoragent@cursor.com>
// MARK: is Xcode's convention, carried over from the Swift side. The Java sources mark sections with // ---- title ----, so these do too. Co-authored-by: Cursor <cursoragent@cursor.com>
A file past twice the batch size cannot be this version's, and read anyway it is held in memory whole. One large enough failed expiry, which runs first in every delivery, on every pass, without ever being deleted. Such a file is now unreadable before anything is allocated: a batch is dropped and counted lost by delivery, a log from a previous run is deleted and counted lost by recovery, and expiry discards it as one event. Memory short at the allocation is a read that may pass later. Expiry and recovery leave a file that throws for a later pass rather than ending the pass. Co-authored-by: Cursor <cursoragent@cursor.com>
Batches are named ready-v1-<payloadId>.<count>. Nothing is taken from a ready- name of another version: it is counted by reading the file, whose header decides whether this version can deliver it, and given a payload ID made from its name, the same for every attempt from every process. A later version can then change its names however it likes, and an earlier one pays a read for it rather than taking some other number for the count. Co-authored-by: Cursor <cursoragent@cursor.com>
A listing that reads a batch named without a count took its size from a stat of its own afterwards, which answers 0 where it cannot look. With no earlier size to fall back on, a batch of up to 2 MiB was then counted as a header's six bytes against the byte capacity. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This is the first version, so a ready- batch not named v1- can only be a later version's, after a downgrade. Reading it is that version's to do: it is no longer listed, counted against capacity or delivered under a payload ID made up for it. Expiry still deletes it once it is past the maximum age, without reading or counting it, so it cannot stay on the device for good. Co-authored-by: Cursor <cursoragent@cursor.com>
…-durability-tier2-bounded-flush
…y/event-durability-tier3-persistence
| * process, its events lost without being counted. | ||
| */ | ||
| private static byte[] readAll(FileInputStream in) throws IOException { | ||
| byte[] contents = new byte[(int) Math.min(in.getChannel().size(), Integer.MAX_VALUE - 8)]; |
There was a problem hiding this comment.
Do we still need this Math.min check if we have in.getChannel().size?
Semi related, where do we impose a maximum size we are willing to read that matches the 2MB max file size we're expecting these to be?
There was a problem hiding this comment.
try (SeekableByteChannel sbc = Files.newByteChannel(path);
InputStream in = Channels.newInputStream(sbc)) {
long size = sbc.size();
if (size > (long)MAX_BUFFER_SIZE)
throw new OutOfMemoryError("Required array size too large");
return read(in, (int)size);
}```
The test listed in a tight loop with a yield between listings. A monitor is not fair, and a yield returns at once when the closing thread is blocked on it rather than runnable, so the lister took the lock straight back. On a busy CI runner the 150 closes ran past the 30 second timeout. A 200 microsecond pause lets each close in while a listing still overlaps it. Co-authored-by: Cursor <cursoragent@cursor.com>
ConnectivityManager reports, to an event processor that listens, the start of each data source's first flag request and how it ended: loaded, failed, or stopped before it answered. A poll a fresh cache put off makes no request at launch and reports nothing. Each source reports once. Groundwork for the launch gate and pacing (spec D16-D20). Co-authored-by: Cursor <cursoragent@cursor.com>
Where events persist: - What a previous run left waits for the launch gate: at least 2 s after first going online, and until the first flag request settles, there was none, or 10 s have passed. The diagnostic init event waits for it too. - Deliveries the processor starts on its own (the launch delivery and the timer) send one batch at a time, at least 1 s after the last answered, and hold while a flag request is in flight, for up to 10 s. - A flush the application asks for, going offline, and close are never held, and end the pacing of a delivery under way. - Recovery of a previous run's logs runs at the lowest thread priority. At DISABLED nothing changes. Spec D16-D20. 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 2 potential issues.
❌ 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 33af63a. Configure here.
capacityBytes was each environment's, so an application configured for several environments could keep that many times the bytes it set. It is now for all of them together, each environment's store getting an equal share. The event capacity is still each environment's in full. Co-authored-by: Cursor <cursoragent@cursor.com>
…aunch cap is not waited twice Co-authored-by: Cursor <cursoragent@cursor.com>
…e it answers Co-authored-by: Cursor <cursoragent@cursor.com>
…post Co-authored-by: Cursor <cursoragent@cursor.com>
| flagRequestAnswered = answered; | ||
| if (requestsFlags && eventProcessor instanceof FlagRequestListener) { | ||
| ((FlagRequestListener) eventProcessor).flagRequestStarted(); | ||
| } |
There was a problem hiding this comment.
This seems hacky, and also a bit of a lie since it is saying a request was answered, but in this case no request was sent.
| private final LDContext context; | ||
| private final DataSourceUpdateSink dataSourceUpdateSink; | ||
| final long initialDelayMillis; // visible for testing | ||
| final long initialDelayMillis; // visible for testing, and to ConnectivityManager |
There was a problem hiding this comment.
This is a hack to use in real code.
| } | ||
|
|
||
| /** A data source started loading flags: at launch, at {@code identify}, or reconnecting. */ | ||
| void flagRequestStarted(); |
There was a problem hiding this comment.
I'm not sure this should be communicating the request/response information to other parts of the code if avoidable.
Why not just communicate that one component wants network layer priority? Then the component can hold network layer priority across multiple requests and we don't need to have the concept of loaded, failed, stopped permeating out of this API into other parts of the code.
| /** Whether the delivery running is one this processor started on its own, and so paced. */ | ||
| private boolean pacedPass; | ||
| private int pacedPostsInPass; | ||
| private long lastPacedPostEndedNanos; |
There was a problem hiding this comment.
This seems like way too much logic / pollution of concerns to be embedded in the direct event processor itself. This is approaching a QoS system and that is too much to be in this class.
There was a problem hiding this comment.
refactored into EventDeliveryScheduler
|
|
||
| private final Object lock = new Object(); | ||
| // Guarded by lock. | ||
| private boolean inFlight; |
There was a problem hiding this comment.
We should ideally not reveal from one component needing network priority that it has requests in flight. It really should just be that it needs the network layer bandwidth and what it will do with that bandwidth is up to it. It may make a thousand requests, who knows, other portions of our code shouldn't need to know.
…g requests Flag request tracking is removed: FlagTraffic, FlagRequestListener and the reporting from ConnectivityManager. It covered only the request a data source starts with, and each data source and mode settles differently, so there were more cases it did not see than it did. What stays is timing. Where events persist, a previous run's events and the diagnostic init event wait 2 seconds after the SDK first goes online, and the deliveries the SDK starts on its own leave at least 1 second between one batch's answer and the next batch's request. A flush, backgrounding, going offline and close end the pacing as before. At DISABLED nothing waits. Co-authored-by: Cursor <cursoragent@cursor.com>
…gnosticReporter, as on iOS Co-authored-by: Cursor <cursoragent@cursor.com>
… EventCommitter Co-authored-by: Cursor <cursoragent@cursor.com>
DeliveryScheduler owns what puts deliveries on the scheduler thread: the requested delivery queued and not started, the one retry, the submit/shutdown handover and the pacing wake-up. DeliveryRun carries one pass: whether it posts through the backoff, and its pacing gap. The processor passes the run down the delivery chain instead of setting scheduler-thread fields around it. 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>

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-internal
DefaultEventProcessorwrapper with a disk-backed pipeline:DirectEventProcessor,EventCommitter,EventStore/DiskEventLog,AnalyticsEventSender(stableX-LaunchDarkly-Payload-IDper batch),BatchLocks, andDeliveryRetryState. Apps can opt in viaEventPersistence(DISABLED/DEFERRED/IMMEDIATE) onEventProcessorBuilder, with events stored undergetNoBackupFilesDir()per mobile key.Behavior changes: persisted batches survive process death and are sent after a launch delay that yields to flag traffic;
flushAsync()/flushAndWaitreport failure when accepted events were lost or delivery fails (new instrumented tests cover crash handler flush, multi-env parallel delivery, and edge timeouts).LDClient.initin the example app uses a blocking init with a wait seconds parameter instead ofFuture.get. Default event capacity rises to 1000;launchdarkly-java-sdk-internalbumps to 1.12.2.Supporting work: dedicated commit and diagnostic executors, optional
LD_EVENT_BENCHdevice/JVM benchmarks, AndroidBatchLocksAndroidTest, Gradle tweaks for bench runs, and minor example/.gitignore cleanup.Reviewed by Cursor Bugbot for commit 72508f5. Bugbot is set up for automated code reviews on this repo. Configure here.