Repository navigation
feat(events): persist events to disk so they survive the process (tier 3) #538
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: andrey/event-durability-tier2-bounded-flush
Are you sure you want to change the base?
Changes from all commits
845a595
0c5c675
1307ed4
7df86a3
87d3926
183ee21
312dbb6
a952a9b
95b56af
ebc8aef
b5a88dd
2c46b87
126db1c
87ec45b
4bac993
e995b26
7f803e4
3fcd1f5
6406d5d
0c8ea66
6a310fa
f767a89
ca5078e
fe918c6
2b892df
af218b1
fd73b60
2b25a35
e5a4e43
61ba495
12cb3e2
a76eda9
a901782
8bffc83
81a7856
425aefb
12cc8f0
a65b8fe
ea815e2
35ae12e
4fcc3e0
d32c669
40ab485
20203d9
4ab5f5d
8d9c131
65b2bbf
79ddb32
ab53b77
1db8cc7
3668890
d6b1e95
f2a8bd6
6a6afc8
50ebcd0
8ef1c2a
f289900
d95893a
2f180e4
409b076
b85328d
b71a6cb
7c3aab6
53af9ee
35453a8
8bf63c6
7dbc8ea
1ae965d
60c1965
eaa67a4
8d59683
d4f6e06
109372d
3aaa3b8
9d0b789
ea0b069
49a4c56
1baabd6
35163da
19192a3
51edd86
690f52e
188bb59
4be95a8
a8362df
8a6af04
067e676
1cdd573
4d33b28
e29e9e2
82322ad
0533f11
5ad00a1
b21ef40
95957b9
edb95e7
91a301a
e3c64b8
2fa7afc
1e7616c
8004c28
d4f35f3
2a7cf5b
0586bdf
f4adfaf
2b1e380
cc200a6
4c30257
1b0a80d
4e049fd
d506d34
5d04131
051ff2b
288a27b
b55c839
0124fc8
307eaa7
d5ca597
6506c92
5c1e5b8
6093548
bd3b796
b94ce8e
c438c9e
455028b
68ce4c4
24ba5b2
8319688
5028134
0083526
8676b9f
874c11a
8b6e2d6
07ed8c5
e6514b9
bc29c3d
2887cd3
b1a7ed1
a015c89
cedff6a
53eb687
f2b515e
9e629b6
9913306
8a8a670
bcd41ae
0d4e4a4
fdaf882
8d97c41
af90a9c
f059653
6877ac1
2a1d1a8
5ea2749
9bda454
18e73a6
7117c2f
013d2b7
8e6abfa
d62cd58
93f6ed5
4ae9292
5c61258
9173f03
d0ee14b
9721730
739a951
e3684a8
98ad649
635daf1
f2793c1
6c68152
6d6875c
b55af5d
5711315
7bebd16
b5e81ef
7fa137c
d052e1c
793c3b9
bcbde8a
5a04c20
b3c9ff3
3f8bab5
1c29a9b
373a338
4be022a
8f265da
c788e2e
22b7bae
374fefc
8c8a292
71fc143
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import Foundation | ||
|
|
||
| extension Crc32 { | ||
| /// Advance the current checksum with bytes already in memory, without wrapping them in `Data` first. | ||
| /// - parameter bytes: bytes to advance the checksum | ||
| mutating func advance(withBytes bytes: UnsafeRawBufferPointer) { | ||
| guard let base = bytes.baseAddress?.assumingMemoryBound(to: UInt8.self) | ||
| else { return } | ||
| if let fastCrc32 = Crc32.zLibCrc32 { | ||
| checksum = fastCrc32(checksum, base, UInt32(bytes.count)) | ||
| } else { | ||
| checksum = slowCrc32(start: checksum, data: Data(bytes)) | ||
| } | ||
|
tanderson-ld marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,9 @@ public class LDClient { | |
|
|
||
| static var instances: [String: LDClient]? | ||
| static let instancesQueue = DispatchQueue(label: "com.launchdarkly.LDClient.instancesQueue") | ||
| /// The `close()` calls still closing the clients they took from `instances`. Entered on `instancesQueue`, in the | ||
| /// same step that takes them, so that a `start()` checking it there cannot miss one. | ||
| private static let closingClients = DispatchGroup() | ||
|
|
||
| // If the SDK is provided a timeout value that exceeds this value, a warning will be logged. | ||
| static let longTimeoutInterval: TimeInterval = 15 | ||
|
|
@@ -66,7 +69,7 @@ public class LDClient { | |
| let oldValue = _isOnline | ||
| _isOnline = newValue | ||
| flagSynchronizer.isOnline = _isOnline | ||
| eventReporter.isOnline = _isOnline | ||
| eventProcessor.isOnline = _isOnline | ||
| diagnosticReporter.setMode(runMode, online: _isOnline) | ||
| if _isOnline != oldValue { | ||
| connectionInformation = ConnectionInformation.onlineSetCheck(connectionInformation: connectionInformation, ldClient: self, config: config, online: _isOnline) | ||
|
|
@@ -247,39 +250,28 @@ public class LDClient { | |
| There is almost no reason to stop the LDClient. Normally, set the LDClient offline to stop communication with the LaunchDarkly servers. Stop the LDClient to stop recording events. There is no need to stop the LDClient prior to suspending, moving to the background, or terminating the app. The SDK will respond to these events as the system requires and as configured in LDConfig. | ||
| */ | ||
| public func close() { | ||
| LDClient.instancesQueue.sync(flags: .barrier) { | ||
| LDClient.instances?.forEach { $1.internalClose() } | ||
| LDClient.instances = nil | ||
| // Closed outside `instancesQueue`: each close can wait on its final event commit, and every other call that | ||
| // reads `instances` would wait behind it. Only `start()` waits, so that a new client does not open the event | ||
| // store of one still committing. | ||
| let clients = LDClient.instancesQueue.sync(flags: .barrier) { () -> [LDClient] in | ||
| defer { LDClient.instances = nil } | ||
| LDClient.closingClients.enter() | ||
| return LDClient.instances.map { Array($0.values) } ?? [] | ||
| } | ||
| defer { LDClient.closingClients.leave() } | ||
| clients.forEach { $0.internalClose() } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| private func internalClose() { | ||
| os_log("%s stopping", log: config.logger, type: .debug, typeName(and: #function)) | ||
| internalFlush() | ||
| eventProcessor.flushAtClose() | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| internalSetOnline(false) | ||
| hasStarted = false | ||
| os_log("%s stopped", log: config.logger, type: .debug, typeName(and: #function)) | ||
| } | ||
|
|
||
| @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. Only where suspension | ||
| // is real: macOS posts this notification whenever the application loses focus, and never suspends it for that. | ||
| #if os(iOS) || os(tvOS) | ||
| backgroundDelivery.run { [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() | ||
| } | ||
| } | ||
| #endif | ||
| Thread.performOnMain { | ||
| runMode = .background | ||
| } | ||
|
|
@@ -292,9 +284,6 @@ public class LDClient { | |
| } | ||
| } | ||
|
|
||
| /// One assertion per client, however many times the application is backgrounded while a delivery is running. | ||
| private let backgroundDelivery: BackgroundActivity | ||
|
|
||
| let config: LDConfig | ||
| /// Identifies this client's environment to a hook, without handing it the mobile key that identifies the | ||
| /// environment to LaunchDarkly. Hashed once here rather than per evaluation, because a deduping hook asks for it on | ||
|
|
@@ -475,7 +464,7 @@ public class LDClient { | |
|
|
||
| internalIdentifyQueue.sync { | ||
| if self.context == updatedContext { | ||
| self.eventReporter.record(IdentifyEvent(context: self.context)) | ||
| self.eventProcessor.record(IdentifyEvent(context: self.context)) | ||
| completion?() | ||
| return | ||
| } | ||
|
|
@@ -525,7 +514,7 @@ public class LDClient { | |
| onSyncComplete: self.onFlagSyncComplete) | ||
|
|
||
| if self.hasStarted { | ||
| self.eventReporter.record(IdentifyEvent(context: self.context)) | ||
| self.eventProcessor.record(IdentifyEvent(context: self.context)) | ||
| } | ||
|
|
||
| if self.isOnline && self.isInSupportedRunMode { | ||
|
|
@@ -769,7 +758,7 @@ public class LDClient { | |
| key, | ||
| String(describing: data), | ||
| String(describing: metricValue)) | ||
| eventReporter.record(event) | ||
| eventProcessor.record(event) | ||
|
|
||
| executeAfterTrackHooks(key: key, data: data, metricValue: metricValue) | ||
| } | ||
|
|
@@ -849,26 +838,36 @@ public class LDClient { | |
| } | ||
| } | ||
|
|
||
| /// Marks a start in progress, once any close in progress has closed its clients. False where there are clients, or | ||
| /// another start has claimed them already. | ||
| private static func claimInstances() -> Bool { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI review comment: Medium, on the new closing gate. The group covers internalClose() but not the work the commit message names: the closing delivery's commitAndCloseBatch and the store's deinit both land after leave() (verified ordering: zero batches closed and zero posts at the moment flushAtClose returns). The proven residual is accounting: a replacement store counts the directory as empty until its first listing completes (which runs behind recovery on a utility queue), so two live stores admit up to twice the configured capacity (40k events / 32 MiB at defaults), and an incomplete first listing extends that. Extending the gate through the closing delivery's completion, or counting the directory synchronously at store construction (the count-in-filename design makes that one listing, no reads), closes it. |
||
| while true { | ||
| closingClients.wait() | ||
| let claimed = instancesQueue.sync(flags: .barrier) { () -> Bool? in | ||
| guard closingClients.wait(timeout: .now()) == .success | ||
| else { return nil } | ||
| guard instances == nil | ||
| else { return false } | ||
| // initializing instances to empty list acts as a initialization in progress flag to avoid other threads | ||
| // entering this function again | ||
| instances = [:] | ||
| return true | ||
| } | ||
| if let claimed { | ||
| return claimed | ||
| } | ||
| } | ||
| } | ||
|
|
||
| static func start(serviceFactory: ClientServiceCreating?, config: LDConfig, context: LDContext? = nil, completion: (() -> Void)? = nil) { | ||
|
|
||
| if serviceFactory != nil { | ||
| get()?.close() | ||
| } | ||
|
|
||
| var shouldCreateInstances = false | ||
| instancesQueue.sync(flags: .barrier) { | ||
| if instances != nil { | ||
| os_log("%s LDClient.start() was called more than once!", log: config.logger, type: .debug, typeName(and: #function)) | ||
| shouldCreateInstances = false | ||
| } else { | ||
| // initializing instances to empty list acts as a initialization in progress flag to avoid other threads | ||
| // entering this function again | ||
| LDClient.instances = [:] | ||
| shouldCreateInstances = true | ||
| } | ||
| } | ||
|
|
||
| if !shouldCreateInstances { | ||
| guard claimInstances() | ||
| else { | ||
| os_log("%s LDClient.start() was called more than once!", log: config.logger, type: .debug, typeName(and: #function)) | ||
| return | ||
| } | ||
|
|
||
|
|
@@ -979,7 +978,7 @@ public class LDClient { | |
| private(set) var flagCache: FeatureFlagCaching | ||
| private(set) var flagSynchronizer: LDFlagSynchronizing | ||
| var flagChangeNotifier: FlagChangeNotifying | ||
| private(set) var eventReporter: EventReporting | ||
| private(set) var eventProcessor: EventProcessor | ||
| private(set) var environmentReporter: EnvironmentReporting | ||
| private(set) var throttler: Throttling | ||
| private(set) var diagnosticReporter: DiagnosticReporting | ||
|
|
@@ -1035,14 +1034,13 @@ public class LDClient { | |
| throttler = self.serviceFactory.makeThrottler(environmentReporter: environmentReporter) | ||
|
|
||
| config = configuration | ||
| backgroundDelivery = BackgroundActivity(reason: "LaunchDarkly event delivery", logger: configuration.logger) | ||
| context = startContext ?? LDContext() | ||
|
|
||
| if config.autoEnvAttributes { | ||
| context = AutoEnvContextModifier(environmentReporter: environmentReporter, logger: config.logger).modifyContext(context) | ||
| } | ||
|
|
||
| var hookState: IdentifyHookState? = nil | ||
| var hookState: IdentifyHookState? | ||
| if !hooks.isEmpty { | ||
| let seriesContext = IdentifySeriesContext(context: context, methodName: "init") | ||
| let seriesData = hooks.map { hook in hook.beforeIdentify(seriesContext: seriesContext, seriesData: EvaluationSeriesData()) } | ||
|
|
@@ -1054,7 +1052,7 @@ public class LDClient { | |
| // Reports over the logger rather than over this client, which is not usable until every stored property | ||
| // below has a value, so that the reporter can be built once rather than built and replaced. | ||
| let logger = configuration.logger | ||
| eventReporter = self.serviceFactory.makeEventReporter(config: config, service: service) { result in | ||
| eventProcessor = self.serviceFactory.makeEventProcessor(config: config, service: service) { result in | ||
| LDClient.logEventSyncComplete(result, logger) | ||
| } | ||
| connectionInformation = self.serviceFactory.makeConnectionInformation() | ||
|
|
@@ -1069,6 +1067,7 @@ public class LDClient { | |
| if let backgroundNotification = SystemCapabilities.backgroundNotification { | ||
| NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: backgroundNotification, object: nil) | ||
| } | ||
| LDClient.observeBackgroundingForEvents() | ||
| if let foregroundNotification = SystemCapabilities.foregroundNotification { | ||
| NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: foregroundNotification, object: nil) | ||
| } | ||
|
|
@@ -1078,12 +1077,13 @@ public class LDClient { | |
| service.resetFlagResponseCache(etag: cachedData.etag) | ||
| // Reports to this client, so it could not be given at construction: `self` is only usable from here. | ||
| flagSynchronizer.onSyncComplete = onFlagSyncComplete | ||
| giveFlagValuesTheNetworkFirst() | ||
|
|
||
| if let cachedFlags = cachedData.items, !cachedFlags.isEmpty { | ||
| flagStore.replaceStore(newStoredItems: cachedFlags) | ||
| } | ||
|
|
||
| eventReporter.record(IdentifyEvent(context: context)) | ||
| eventProcessor.record(IdentifyEvent(context: context)) | ||
| self.connectionInformation = ConnectionInformation.uncacheConnectionInformation(config: config, ldClient: self, clientServiceFactory: self.serviceFactory) | ||
|
|
||
| internalSetOnline(configuration.startOnline) { | ||
|
|
@@ -1098,12 +1098,35 @@ public class LDClient { | |
|
|
||
| extension LDClient: TypeIdentifying { } | ||
|
|
||
| private extension LDClient { | ||
| 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)) | ||
| extension LDClient { | ||
| /// Has the diagnostic init event wait for the event processor's launch gate, as a previous run's events do, whether | ||
| /// or not events persist. | ||
| fileprivate func giveFlagValuesTheNetworkFirst() { | ||
| if let eventProcessor = eventProcessor as? DirectEventProcessor { | ||
| (diagnosticReporter as? DiagnosticReporter)?.sendsInitAfter = eventProcessor.whenLaunchGateOpens | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI review comment: High, new in 6d6875c, partly a spec decision. Three facets: (1) the launch gate now applies at |
||
| } | ||
| } | ||
|
|
||
| /// Observes the notification once for the events of every environment, however many clients are made. | ||
| private static let backgroundEvents: BackgroundEventDelivery? = { | ||
| guard let backgroundNotification = SystemCapabilities.backgroundNotification | ||
| else { return nil } | ||
| let delivery = BackgroundEventDelivery(environments: LDClient.currentEnvironments) | ||
| // Run on the posting thread, so that every environment has committed by the time the notification returns. | ||
| NotificationCenter.default.addObserver(forName: backgroundNotification, object: nil, queue: nil) { _ in | ||
| delivery.applicationDidEnterBackground() | ||
| } | ||
| return delivery | ||
| }() | ||
|
|
||
| fileprivate static func observeBackgroundingForEvents() { | ||
| _ = backgroundEvents | ||
| } | ||
|
|
||
| private static func currentEnvironments() -> [BackgroundEventDelivery.Environment] { | ||
| let clients = instancesQueue.sync { instances } ?? [:] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI review comment: High, new in 7fa137c. The unified backgrounding observer runs on the posting thread (main) and calls |
||
| return clients.map { name, client in | ||
| BackgroundEventDelivery.Environment(name: name, eventProcessor: client.eventProcessor, logger: client.config.logger) | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.