Skip to content

Commit 78f970b

Browse files
authored
[CoreInternal] Add async flush method (#13850)
1 parent 7c4659c commit 78f970b

File tree

6 files changed

+261
-3
lines changed

6 files changed

+261
-3
lines changed

FirebaseCore/Internal/Sources/HeartbeatLogging/HeartbeatController.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,38 @@ public final class HeartbeatController {
126126
}
127127
}
128128

129+
@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 6.0, *)
130+
public func flushAsync() async -> HeartbeatsPayload {
131+
return await withCheckedContinuation { continuation in
132+
let resetTransform = { (heartbeatsBundle: HeartbeatsBundle?) -> HeartbeatsBundle? in
133+
guard let oldHeartbeatsBundle = heartbeatsBundle else {
134+
return nil // Storage was empty.
135+
}
136+
// The new value that's stored will use the old's cache to prevent the
137+
// logging of duplicates after flushing.
138+
return HeartbeatsBundle(
139+
capacity: self.heartbeatsStorageCapacity,
140+
cache: oldHeartbeatsBundle.lastAddedHeartbeatDates
141+
)
142+
}
143+
144+
// Asynchronously gets and returns the stored heartbeats, resetting storage
145+
// using the given transform.
146+
storage.getAndSetAsync(using: resetTransform) { result in
147+
switch result {
148+
case let .success(heartbeatsBundle):
149+
// If no heartbeats bundle was stored, return an empty payload.
150+
continuation
151+
.resume(returning: heartbeatsBundle?.makeHeartbeatsPayload() ?? HeartbeatsPayload
152+
.emptyPayload)
153+
case .failure:
154+
// If the operation throws, assume no heartbeat(s) were retrieved or set.
155+
continuation.resume(returning: HeartbeatsPayload.emptyPayload)
156+
}
157+
}
158+
}
159+
}
160+
129161
/// Synchronously flushes the heartbeat for today.
130162
///
131163
/// If no heartbeat was logged today, the returned payload is empty.

FirebaseCore/Internal/Sources/HeartbeatLogging/HeartbeatStorage.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ protocol HeartbeatStorageProtocol {
2020
func readAndWriteAsync(using transform: @escaping (HeartbeatsBundle?) -> HeartbeatsBundle?)
2121
func getAndSet(using transform: (HeartbeatsBundle?) -> HeartbeatsBundle?) throws
2222
-> HeartbeatsBundle?
23+
func getAndSetAsync(using transform: @escaping (HeartbeatsBundle?) -> HeartbeatsBundle?,
24+
completion: @escaping (Result<HeartbeatsBundle?, Error>) -> Void)
2325
}
2426

2527
/// Thread-safe storage object designed for transforming heartbeat data that is persisted to disk.
@@ -134,6 +136,27 @@ final class HeartbeatStorage: HeartbeatStorageProtocol {
134136
return heartbeatsBundle
135137
}
136138

139+
/// Asynchronously gets the current heartbeat data from storage and resets the storage using the
140+
/// given transform block.
141+
/// - Parameters:
142+
/// - transform: An escaping block used to reset the currently stored heartbeat.
143+
/// - completion: An escaping block used to process the heartbeat data that
144+
/// was stored (before the `transform` was applied); otherwise, the error
145+
/// that occurred.
146+
func getAndSetAsync(using transform: @escaping (HeartbeatsBundle?) -> HeartbeatsBundle?,
147+
completion: @escaping (Result<HeartbeatsBundle?, Error>) -> Void) {
148+
queue.async {
149+
do {
150+
let oldHeartbeatsBundle = try? self.load(from: self.storage)
151+
let newHeartbeatsBundle = transform(oldHeartbeatsBundle)
152+
try self.save(newHeartbeatsBundle, to: self.storage)
153+
completion(.success(oldHeartbeatsBundle))
154+
} catch {
155+
completion(.failure(error))
156+
}
157+
}
158+
}
159+
137160
/// Loads and decodes the stored heartbeats bundle from a given storage object.
138161
/// - Parameter storage: The storage container to read from.
139162
/// - Returns: The decoded `HeartbeatsBundle` loaded from storage; `nil` if storage is empty.

FirebaseCore/Internal/Sources/HeartbeatLogging/_ObjC_HeartbeatController.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ public class _ObjC_HeartbeatController: NSObject {
4545
return _ObjC_HeartbeatsPayload(heartbeatsPayload)
4646
}
4747

48+
/// Asynchronously flushes heartbeats from storage into a heartbeats payload.
49+
///
50+
/// - Note: This API is thread-safe.
51+
/// - Returns: A heartbeats payload for the flushed heartbeat(s).
52+
@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 6.0, *)
53+
public func flushAsync() async -> _ObjC_HeartbeatsPayload {
54+
let heartbeatsPayload = await heartbeatController.flushAsync()
55+
return _ObjC_HeartbeatsPayload(heartbeatsPayload)
56+
}
57+
4858
/// Synchronously flushes the heartbeat for today.
4959
///
5060
/// If no heartbeat was logged today, the returned payload is empty.

FirebaseCore/Internal/Tests/Integration/HeartbeatLoggingIntegrationTests.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,31 @@ class HeartbeatLoggingIntegrationTests: XCTestCase {
5252
)
5353
}
5454

55+
@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 6.0, *)
56+
func testLogAndFlushAsync() async throws {
57+
// Given
58+
let heartbeatController = HeartbeatController(id: #function)
59+
let expectedDate = HeartbeatsPayload.dateFormatter.string(from: Date())
60+
// When
61+
heartbeatController.log("dummy_agent")
62+
let payload = await heartbeatController.flushAsync()
63+
// Then
64+
try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
65+
payload.headerValue(),
66+
"""
67+
{
68+
"version": 2,
69+
"heartbeats": [
70+
{
71+
"agent": "dummy_agent",
72+
"dates": ["\(expectedDate)"]
73+
}
74+
]
75+
}
76+
"""
77+
)
78+
}
79+
5580
/// This test may flake if it is executed during the transition from one day to the next.
5681
func testDoNotLogMoreThanOnceInACalendarDay() throws {
5782
// Given

FirebaseCore/Internal/Tests/Unit/HeartbeatControllerTests.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,39 @@ class HeartbeatControllerTests: XCTestCase {
5858
assertHeartbeatControllerFlushesEmptyPayload(controller)
5959
}
6060

61+
@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 6.0, *)
62+
func testLogAndFlushAsync() async throws {
63+
// Given
64+
let controller = HeartbeatController(
65+
storage: HeartbeatStorageFake(),
66+
dateProvider: { self.date }
67+
)
68+
69+
assertHeartbeatControllerFlushesEmptyPayload(controller)
70+
71+
// When
72+
controller.log("dummy_agent")
73+
let heartbeatPayload = await controller.flushAsync()
74+
75+
// Then
76+
try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
77+
heartbeatPayload.headerValue(),
78+
"""
79+
{
80+
"version": 2,
81+
"heartbeats": [
82+
{
83+
"agent": "dummy_agent",
84+
"dates": ["2021-11-01"]
85+
}
86+
]
87+
}
88+
"""
89+
)
90+
91+
assertHeartbeatControllerFlushesEmptyPayload(controller)
92+
}
93+
6194
func testLogAtEndOfTimePeriodAndAcceptAtStartOfNextOne() throws {
6295
// Given
6396
var testDate = date
@@ -404,4 +437,15 @@ private class HeartbeatStorageFake: HeartbeatStorageProtocol {
404437
heartbeatsBundle = transform(heartbeatsBundle)
405438
return oldHeartbeatsBundle
406439
}
440+
441+
func getAndSetAsync(using transform: @escaping (FirebaseCoreInternal.HeartbeatsBundle?)
442+
-> FirebaseCoreInternal.HeartbeatsBundle?,
443+
completion: @escaping (Result<
444+
FirebaseCoreInternal.HeartbeatsBundle?,
445+
any Error
446+
>) -> Void) {
447+
let oldHeartbeatsBundle = heartbeatsBundle
448+
heartbeatsBundle = transform(heartbeatsBundle)
449+
completion(.success(oldHeartbeatsBundle))
450+
}
407451
}

FirebaseCore/Internal/Tests/Unit/HeartbeatStorageTests.swift

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,46 @@ class HeartbeatStorageTests: XCTestCase {
208208
wait(for: [expectation], timeout: 0.5)
209209
}
210210

211+
func testGetAndSetAsync_ReturnsOldValueAndSetsNewValue() throws {
212+
// Given
213+
let heartbeatStorage = HeartbeatStorage(id: #function, storage: StorageFake())
214+
215+
var dummyHeartbeatsBundle = HeartbeatsBundle(capacity: 1)
216+
dummyHeartbeatsBundle.append(Heartbeat(agent: "dummy_agent", date: Date()))
217+
218+
// When
219+
let expectation1 = expectation(description: #function + "_1")
220+
heartbeatStorage.getAndSetAsync { heartbeatsBundle in
221+
// Assert that heartbeat storage is empty.
222+
XCTAssertNil(heartbeatsBundle)
223+
// Write new value.
224+
return dummyHeartbeatsBundle
225+
} completion: { result in
226+
switch result {
227+
case .success: break
228+
case let .failure(error): XCTFail("Error: \(error)")
229+
}
230+
expectation1.fulfill()
231+
}
232+
233+
// Then
234+
let expectation2 = expectation(description: #function + "_2")
235+
XCTAssertNoThrow(
236+
try heartbeatStorage.getAndSet { heartbeatsBundle in
237+
// Assert old value is read.
238+
XCTAssertEqual(
239+
heartbeatsBundle?.makeHeartbeatsPayload(),
240+
dummyHeartbeatsBundle.makeHeartbeatsPayload()
241+
)
242+
// Write some new value.
243+
expectation2.fulfill()
244+
return heartbeatsBundle
245+
}
246+
)
247+
248+
wait(for: [expectation1, expectation2], timeout: 0.5, enforceOrder: true)
249+
}
250+
211251
func testGetAndSet_WhenLoadFails_PassesNilToBlockAndReturnsNil() throws {
212252
// Given
213253
let expectation = expectation(description: #function)
@@ -232,6 +272,41 @@ class HeartbeatStorageTests: XCTestCase {
232272
wait(for: [expectation], timeout: 0.5)
233273
}
234274

275+
func testGetAndSetAsync_WhenLoadFails_PassesNilToBlockAndReturnsNil() throws {
276+
// Given
277+
let readExpectation = expectation(description: #function + "_1")
278+
let transformExpectation = expectation(description: #function + "_2")
279+
let completionExpectation = expectation(description: #function + "_3")
280+
281+
let storageFake = StorageFake()
282+
let heartbeatStorage = HeartbeatStorage(id: #function, storage: storageFake)
283+
284+
// When
285+
storageFake.onRead = {
286+
readExpectation.fulfill()
287+
return try XCTUnwrap("BAD_DATA".data(using: .utf8))
288+
}
289+
290+
// Then
291+
heartbeatStorage.getAndSetAsync { heartbeatsBundle in
292+
XCTAssertNil(heartbeatsBundle)
293+
transformExpectation.fulfill()
294+
return heartbeatsBundle
295+
} completion: { result in
296+
switch result {
297+
case .success: break
298+
case let .failure(error): XCTFail("Error: \(error)")
299+
}
300+
completionExpectation.fulfill()
301+
}
302+
303+
wait(
304+
for: [readExpectation, transformExpectation, completionExpectation],
305+
timeout: 0.5,
306+
enforceOrder: true
307+
)
308+
}
309+
235310
func testGetAndSet_WhenSaveFails_ThrowsError() throws {
236311
// Given
237312
let expectation = expectation(description: #function)
@@ -250,7 +325,42 @@ class HeartbeatStorageTests: XCTestCase {
250325
wait(for: [expectation], timeout: 0.5)
251326
}
252327

253-
func testOperationsAreSynrononizedSerially() throws {
328+
func testGetAndSetAsync_WhenSaveFails_ThrowsError() throws {
329+
// Given
330+
let transformExpectation = expectation(description: #function + "_1")
331+
let writeExpectation = expectation(description: #function + "_2")
332+
let completionExpectation = expectation(description: #function + "_3")
333+
334+
let storageFake = StorageFake()
335+
let heartbeatStorage = HeartbeatStorage(id: #function, storage: storageFake)
336+
337+
// When
338+
storageFake.onWrite = { _ in
339+
writeExpectation.fulfill()
340+
throw StorageError.writeError
341+
}
342+
343+
// Then
344+
heartbeatStorage.getAndSetAsync { heartbeatsBundle in
345+
transformExpectation.fulfill()
346+
XCTAssertNil(heartbeatsBundle)
347+
return heartbeatsBundle
348+
} completion: { result in
349+
switch result {
350+
case .success: XCTFail("Error: unexpected success")
351+
case .failure: break
352+
}
353+
completionExpectation.fulfill()
354+
}
355+
356+
wait(
357+
for: [transformExpectation, writeExpectation, completionExpectation],
358+
timeout: 0.5,
359+
enforceOrder: true
360+
)
361+
}
362+
363+
func testOperationsAreSyncrononizedSerially() throws {
254364
// Given
255365
let heartbeatStorage = HeartbeatStorage(id: #function, storage: StorageFake())
256366

@@ -263,10 +373,24 @@ class HeartbeatStorageTests: XCTestCase {
263373
return heartbeatsBundle
264374
}
265375

266-
if /* randomChoice */ .random() {
376+
switch Int.random(in: 1 ... 3) {
377+
case 1:
267378
heartbeatStorage.readAndWriteAsync(using: transform)
268-
} else {
379+
case 2:
269380
XCTAssertNoThrow(try heartbeatStorage.getAndSet(using: transform))
381+
case 3:
382+
let getAndSet = self.expectation(description: "GetAndSetAsync_\(i)")
383+
heartbeatStorage.getAndSetAsync(using: transform) { result in
384+
switch result {
385+
case .success: break
386+
case let .failure(error):
387+
XCTFail("Unexpected: Error occurred in getAndSet_\(i), \(error)")
388+
}
389+
getAndSet.fulfill()
390+
}
391+
wait(for: [getAndSet], timeout: 1.0)
392+
default:
393+
XCTFail("Unexpected: Random number is out of range.")
270394
}
271395

272396
return expectation

0 commit comments

Comments
 (0)