Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
05b5628
feat(events): add flushAndWait on Apple (tier 2)
abelonogov-ld Sep 19, 2026
5420c2c
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 24, 2026
a4a1bf1
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 25, 2026
41c02e1
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 25, 2026
5842339
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 25, 2026
c0cabc2
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 25, 2026
92f17c9
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 25, 2026
2f8e528
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 28, 2026
86277e6
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 29, 2026
7d70ae6
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 29, 2026
fd1381c
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 29, 2026
dc9d1e6
fix(events): answer a bounded flush about the delivery already in flight
abelonogov-ld Oct 1, 2026
6c3b8f9
shorten comments
abelonogov-ld Oct 1, 2026
57f04c3
fix(events): report a closed client and a refused in-flight delivery …
abelonogov-ld Oct 1, 2026
47700b3
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions LaunchDarkly.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
3D2406222E0D90E000F91253 /* EnvironmentMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D24060F2E0D90E000F91253 /* EnvironmentMetadata.swift */; };
3D2406232E0D90E000F91253 /* SdkMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2406122E0D90E000F91253 /* SdkMetadata.swift */; };
3D2406252E0D90EA00F91253 /* LDClientPluginsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2406242E0D90EA00F91253 /* LDClientPluginsSpec.swift */; };
3D34EEEF37158657D49335BB /* BackgroundActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */; };
3D3AB9432A4F16FE003AECF1 /* ReportingConsts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */; };
3D3AB9442A4F16FE003AECF1 /* ReportingConsts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */; };
3D3AB9452A4F16FE003AECF1 /* ReportingConsts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */; };
Expand All @@ -45,6 +46,7 @@
50EE85C42EA0487F007CC662 /* TimeoutExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */; };
50EE85C52EA0487F007CC662 /* TimeoutExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */; };
50EE85C72EA0749C007CC662 /* TimeoutExecutorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE85C62EA0749C007CC662 /* TimeoutExecutorSpec.swift */; };
7185A3C30E7FFC4F725769F1 /* BackgroundActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */; };
830BF933202D188E006DF9B1 /* HTTPURLRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830BF932202D188E006DF9B1 /* HTTPURLRequest.swift */; };
830DB3AC22380A3E00D65D25 /* HTTPHeadersSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830DB3AB22380A3E00D65D25 /* HTTPHeadersSpec.swift */; };
830DB3AE2239B54900D65D25 /* URLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830DB3AD2239B54900D65D25 /* URLResponse.swift */; };
Expand Down Expand Up @@ -240,6 +242,7 @@
9C4E0F306B7D2158C3E6F0A2 /* JSONWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A8C1D4E7B9053A1C4D8E6 /* JSONWriter.swift */; };
A032A73E446055D14A546CA0 /* EventJSONWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B430884DBF2E541B919C08FA /* EventJSONWriter.swift */; };
A0BAD9EE25943C43A5154E6F /* EventJSONWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 367A9182439001C3EBD68477 /* EventJSONWriterTests.swift */; };
A20146E7AF098560627C614E /* BackgroundActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */; };
B6168D5F889EFC4C19F59135 /* JSONWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BECAB38F41B30401D3CD7EA /* JSONWriterTests.swift */; };
A3047D642A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5D2A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift */; };
A3047D652A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5E2A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift */; };
Expand All @@ -264,6 +267,7 @@
A31088272837DCA900184942 /* LDContextSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31088242837DCA900184942 /* LDContextSpec.swift */; };
A31088282837DCA900184942 /* ReferenceSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31088252837DCA900184942 /* ReferenceSpec.swift */; };
A31088292837DCA900184942 /* KindSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31088262837DCA900184942 /* KindSpec.swift */; };
A32FCDB22D53263851DDB727 /* BackgroundActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */; };
A33A5F7A28466D04000C29C7 /* LDContextStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = A33A5F7928466D04000C29C7 /* LDContextStub.swift */; };
A3470C372B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3470C362B7C1ACE00951CEE /* LDValueDecoder.swift */; };
A3470C382B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3470C362B7C1ACE00951CEE /* LDValueDecoder.swift */; };
Expand Down Expand Up @@ -572,6 +576,7 @@
88DAB8D1864C14D9C7E06F11 /* LDContextJSONWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LDContextJSONWriter.swift; sourceTree = "<group>"; };
9A47F7492F3D4CBF0001FD9C /* ContextSummarizerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextSummarizerSpec.swift; sourceTree = "<group>"; };
9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextSummarizer.swift; sourceTree = "<group>"; };
9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundActivity.swift; sourceTree = "<group>"; };
9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationExposureDeduper.swift; sourceTree = "<group>"; };
9AE1D0A52F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationExposureDeduperSpec.swift; sourceTree = "<group>"; };
9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookDecorator.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1006,6 +1011,7 @@
B430884DBF2E541B919C08FA /* EventJSONWriter.swift */,
6F2A8C1D4E7B9053A1C4D8E6 /* JSONWriter.swift */,
D93B7F5F6CF5C3DE79FB4286 /* ContextEncodingCache.swift */,
9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */,
);
path = ServiceObjects;
sourceTree = "<group>";
Expand Down Expand Up @@ -1529,6 +1535,7 @@
94C1C136BA8989F1B8AFE0A8 /* EventJSONWriter.swift in Sources */,
8B3D9E2F5A6C1047B2D5E9F1 /* JSONWriter.swift in Sources */,
40F4D07AD934AF976687011D /* ContextEncodingCache.swift in Sources */,
A32FCDB22D53263851DDB727 /* BackgroundActivity.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -1620,6 +1627,7 @@
C4AF311E4DA8C3AB7040637D /* EventJSONWriter.swift in Sources */,
9C4E0F306B7D2158C3E6F0A2 /* JSONWriter.swift in Sources */,
4882B223C23B63F30BC60941 /* ContextEncodingCache.swift in Sources */,
7185A3C30E7FFC4F725769F1 /* BackgroundActivity.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -1710,6 +1718,7 @@
9BE9637664F4E97681CFCD45 /* EventJSONWriter.swift in Sources */,
AD5F10417C8E3269D4F701B3 /* JSONWriter.swift in Sources */,
D7A500FCA04E3412F931003F /* ContextEncodingCache.swift in Sources */,
A20146E7AF098560627C614E /* BackgroundActivity.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -1864,6 +1873,7 @@
A032A73E446055D14A546CA0 /* EventJSONWriter.swift in Sources */,
BE6011528D9F4370E50812C4 /* JSONWriter.swift in Sources */,
BAE0B4F45562EBF4AC786B5D /* ContextEncodingCache.swift in Sources */,
3D34EEEF37158657D49335BB /* BackgroundActivity.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
11 changes: 10 additions & 1 deletion LaunchDarkly/GeneratedCode/mocks.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,15 @@ final class EventReportingMock: EventReporting {
flushReceivedCompletion = completion
try! flushCallback?()
}

var flushReportingOutcomeCallCount = 0
var flushReportingOutcomeCallback: (() throws -> Void)?
var flushReportingOutcomeReceivedCompletion: (FlushOutcomeClosure)?
func flushReportingOutcome(completion: @escaping FlushOutcomeClosure) {
flushReportingOutcomeCallCount += 1
flushReportingOutcomeReceivedCompletion = completion
try! flushReportingOutcomeCallback?()
}
}

// MARK: - FeatureFlagCachingMock
Expand Down Expand Up @@ -428,7 +437,7 @@ final class ThrottlingMock: Throttling {

var runThrottledCallCount = 0
var runThrottledCallback: (() throws -> Void)?
var runThrottledReceivedRunClosure: RunClosure?
var runThrottledReceivedRunClosure: (RunClosure)?
func runThrottled(_ runClosure: @escaping RunClosure) {
runThrottledCallCount += 1
runThrottledReceivedRunClosure = runClosure
Expand Down
124 changes: 99 additions & 25 deletions LaunchDarkly/LaunchDarkly/LDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,20 @@ public class LDClient {

@objc private func didEnterBackground() {
os_log("%s", log: config.logger, type: .debug, typeName(and: #function))
// A backgrounded process is suspended once idle, so deliver inside an activity assertion.
BackgroundActivity.run(reason: "LaunchDarkly event delivery") { [weak self] finished in
guard let self = self
else {
finished()
return
}
self.eventReporter.flushReportingOutcome { delivered in
if !delivered {
os_log("%s events did not reach LaunchDarkly before suspension", log: self.config.logger, type: .debug, self.typeName(and: #function))
}
finished()
}
}
Thread.performOnMain {
runMode = .background
}
Expand Down Expand Up @@ -795,31 +809,6 @@ public class LDClient {
plugin.register(client: self, metadata: environmentMetadata)
}

/**
Tells the SDK to immediately send any currently queued events to LaunchDarkly.

There should not normally be a need to call this function. While online, the LDClient automatically reports events
on an interval defined by `LDConfig.eventFlushInterval`. Note that this function does not block until events are
sent, it only triggers a background task to send events immediately.
*/
public func flush() {
LDClient.instancesQueue.sync(flags: .barrier) {
LDClient.instances?.forEach { $1.internalFlush() }
}
}

private func internalFlush() {
eventReporter.flush(completion: nil)
}

private func onEventSyncComplete(result: SynchronizingError?) {
if let synchronizingError = result {
os_log("%s result: %s", log: config.logger, type: .debug, typeName(and: #function), String(describing: synchronizingError))
} else {
os_log("%s result: success", log: config.logger, type: .debug, typeName(and: #function))
}
}

@objc private func didCloseEventSource() {
os_log("%s", log: config.logger, type: .debug, typeName(and: #function))
self.connectionInformation = ConnectionInformation.lastSuccessfulConnectionCheck(connectionInformation: self.connectionInformation)
Expand Down Expand Up @@ -1095,6 +1084,91 @@ public class LDClient {

extension LDClient: TypeIdentifying { }

// MARK: - Event delivery
extension LDClient {
private func onEventSyncComplete(result: SynchronizingError?) {
if let synchronizingError = result {
os_log("%s result: %s", log: config.logger, type: .debug, typeName(and: #function), String(describing: synchronizingError))
} else {
os_log("%s result: success", log: config.logger, type: .debug, typeName(and: #function))
}
}

/**
Tells the SDK to immediately send any currently queued events to LaunchDarkly.

There should not normally be a need to call this function. While online, the LDClient automatically reports events
on an interval defined by `LDConfig.eventFlushInterval`. Note that this function does not block until events are
sent, it only triggers a background task to send events immediately.
*/
public func flush() {
LDClient.instancesQueue.sync(flags: .barrier) {
LDClient.instances?.forEach { $1.internalFlush() }
}
}

/**
Sends any currently queued events to LaunchDarkly and waits up to `timeout` seconds for the result.

The timeout bounds the wait, not the delivery: an in-flight request is left to finish. Multiple environments
share one budget.

This is not a crash-time mechanism. It is safe to call from the main thread, but keep the budget well below
15 seconds there.

- parameter timeout: How long to wait, in seconds.
- returns: Whether the pending events left the SDK's hands within the budget.
*/
@discardableResult
public func flushAndWait(timeout: TimeInterval) -> Bool {
if timeout > LDClient.longTimeoutInterval {
os_log("%s LDClient.flushAndWait was called with a timeout greater than %f seconds. We recommend a timeout of less than %f seconds.", log: config.logger, type: .info, self.typeName(and: #function), LDClient.longTimeoutInterval, LDClient.longTimeoutInterval)
}

guard let clients = LDClient.instancesQueue.sync(execute: { LDClient.instances.map { Array($0.values) } })
else {
os_log("%s called on a closed client", log: config.logger, type: .debug, self.typeName(and: #function))
return false
}
let deadline = Date().addingTimeInterval(max(0, timeout))
var delivered = true
for client in clients {
let remaining = max(0, deadline.timeIntervalSinceNow)
delivered = client.internalFlushAndWait(timeout: remaining) && delivered
}
return delivered
Comment thread
abelonogov-ld marked this conversation as resolved.
}

private func internalFlush() {
eventReporter.flush(completion: nil)
}

/// Neither main (callers may be blocked on it) nor the reporter's queue, so the wait cannot deadlock.
private static let flushWaitQueue = DispatchQueue(label: "com.launchdarkly.flushWait", qos: .userInitiated)

private func internalFlushAndWait(timeout: TimeInterval) -> Bool {
let timeout = max(0, timeout)
let finished = DispatchSemaphore(value: 0)
var delivered = false
TimeoutExecutor.run(
timeout: timeout,
queue: LDClient.flushWaitQueue,
operation: { done in
self.eventReporter.flushReportingOutcome(completion: done)
},
timeoutValue: false,
completion: { result in
delivered = result
finished.signal()
}
)
// A little past the budget, so TimeoutExecutor's timer decides the outcome.
let answered = finished.wait(timeout: .now() + timeout + 0.25)
// `delivered` is only safe to read once the semaphore was signaled.
return answered == .success && delivered
}
}

#if DEBUG
extension LDClient {
func setRunMode(_ runMode: LDClientRunMode) {
Expand Down
14 changes: 14 additions & 0 deletions LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,20 @@ public final class ObjcLDClient: NSObject {
ldClient.flush()
}

/**
Sends any currently queued events and waits up to `timeout` for the delivery to finish.

Returns `YES` if the events were delivered, or there were none to deliver. Returns `NO` if the timeout
expired first, or the SDK is offline, closed, or otherwise unable to deliver them.

This is not a crash-time mechanism. Keep the budget far below 15 seconds when calling from the main thread.

- parameter timeout: How long to wait, in seconds.
*/
@objc public func flushAndWait(timeout: TimeInterval) -> Bool {
ldClient.flushAndWait(timeout: timeout)
}

/**
Starts the LDClient using the passed in `config` & `context`. Call this before requesting feature flag values. The LDClient will not go online until you call this method.
Starting the LDClient means setting the `config` & `context`, setting the client online if `config.startOnline` is true (the default setting), and starting event recording. The client app must start the LDClient before it will report feature flag values. If a client does not call `start`, no methods will work.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

/// Holds a background activity assertion so a backgrounded process is not suspended mid-delivery.
///
/// Uses `ProcessInfo.performExpiringActivity` because the framework is extension-safe and cannot use
/// `UIApplication.beginBackgroundTask`. The system may refuse or end the assertion early.
enum BackgroundActivity {
/// Upper bound for work that never calls `finished`.
private static let maximumDuration: TimeInterval = 30

/// Runs `work` under an assertion released when `work` calls `finished`, the system expires it, or
/// `maximumDuration` passes. Without assertions on this platform, `work` runs on the calling thread.
static func run(reason: String, work: @escaping (_ finished: @escaping () -> Void) -> Void) {
#if os(iOS) || os(tvOS) || os(watchOS)
let finished = DispatchSemaphore(value: 0)
ProcessInfo.processInfo.performExpiringActivity(withReason: reason) { expired in
guard !expired
else {
// Expiration: release the blocked first call.
finished.signal()
return
}
work { finished.signal() }
// The assertion lasts as long as this block, which runs on a system queue, not main.
_ = finished.wait(timeout: .now() + maximumDuration)
}
#else
work {}
#endif
}
}
Loading
Loading