Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,38 @@ public final class HeartbeatController {
}
}

@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 6.0, *)
public func flushAsync() async -> HeartbeatsPayload {
return await withCheckedContinuation { continuation in
let resetTransform = { (heartbeatsBundle: HeartbeatsBundle?) -> HeartbeatsBundle? in
guard let oldHeartbeatsBundle = heartbeatsBundle else {
return nil // Storage was empty.
}
// The new value that's stored will use the old's cache to prevent the
// logging of duplicates after flushing.
return HeartbeatsBundle(
capacity: self.heartbeatsStorageCapacity,
cache: oldHeartbeatsBundle.lastAddedHeartbeatDates
)
}

// Asynchronously gets and returns the stored heartbeats, resetting storage
// using the given transform.
storage.getAndSetAsync(using: resetTransform) { result in
switch result {
case let .success(heartbeatsBundle):
// If no heartbeats bundle was stored, return an empty payload.
continuation
.resume(returning: heartbeatsBundle?.makeHeartbeatsPayload() ?? HeartbeatsPayload
.emptyPayload)
case .failure:
// If the operation throws, assume no heartbeat(s) were retrieved or set.
continuation.resume(returning: HeartbeatsPayload.emptyPayload)
}
}
}
}

/// Synchronously flushes the heartbeat for today.
///
/// If no heartbeat was logged today, the returned payload is empty.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ protocol HeartbeatStorageProtocol {
func readAndWriteAsync(using transform: @escaping (HeartbeatsBundle?) -> HeartbeatsBundle?)
func getAndSet(using transform: (HeartbeatsBundle?) -> HeartbeatsBundle?) throws
-> HeartbeatsBundle?
func getAndSetAsync(using transform: @escaping (HeartbeatsBundle?) -> HeartbeatsBundle?,
completion: @escaping (Result<HeartbeatsBundle?, Error>) -> Void)
}

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

/// Asynchronously gets the current heartbeat data from storage and resets the storage using the
/// given transform block.
/// - Parameters:
/// - transform: An escaping block used to reset the currently stored heartbeat.
/// - completion: An escaping block used to process the heartbeat data that
/// was stored (before the `transform` was applied); otherwise, the error
/// that occurred.
func getAndSetAsync(using transform: @escaping (HeartbeatsBundle?) -> HeartbeatsBundle?,
completion: @escaping (Result<HeartbeatsBundle?, Error>) -> Void) {
queue.async {
do {
let oldHeartbeatsBundle = try? self.load(from: self.storage)
let newHeartbeatsBundle = transform(oldHeartbeatsBundle)
try self.save(newHeartbeatsBundle, to: self.storage)
completion(.success(oldHeartbeatsBundle))
} catch {
completion(.failure(error))
}
}
}

/// Loads and decodes the stored heartbeats bundle from a given storage object.
/// - Parameter storage: The storage container to read from.
/// - Returns: The decoded `HeartbeatsBundle` loaded from storage; `nil` if storage is empty.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ public class _ObjC_HeartbeatController: NSObject {
return _ObjC_HeartbeatsPayload(heartbeatsPayload)
}

/// Asynchronously flushes heartbeats from storage into a heartbeats payload.
///
/// - Note: This API is thread-safe.
/// - Returns: A heartbeats payload for the flushed heartbeat(s).
@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 6.0, *)
public func flushAsync() async -> _ObjC_HeartbeatsPayload {
let heartbeatsPayload = await heartbeatController.flushAsync()
return _ObjC_HeartbeatsPayload(heartbeatsPayload)
}

/// Synchronously flushes the heartbeat for today.
///
/// If no heartbeat was logged today, the returned payload is empty.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ class HeartbeatLoggingIntegrationTests: XCTestCase {
)
}

@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 6.0, *)
func testLogAndFlushAsync() async throws {
// Given
let heartbeatController = HeartbeatController(id: #function)
let expectedDate = HeartbeatsPayload.dateFormatter.string(from: Date())
// When
heartbeatController.log("dummy_agent")
let payload = await heartbeatController.flushAsync()
// Then
try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
payload.headerValue(),
"""
{
"version": 2,
"heartbeats": [
{
"agent": "dummy_agent",
"dates": ["\(expectedDate)"]
}
]
}
"""
)
}

/// This test may flake if it is executed during the transition from one day to the next.
func testDoNotLogMoreThanOnceInACalendarDay() throws {
// Given
Expand Down
44 changes: 44 additions & 0 deletions FirebaseCore/Internal/Tests/Unit/HeartbeatControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,39 @@ class HeartbeatControllerTests: XCTestCase {
assertHeartbeatControllerFlushesEmptyPayload(controller)
}

@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 6.0, *)
func testLogAndFlushAsync() async throws {
// Given
let controller = HeartbeatController(
storage: HeartbeatStorageFake(),
dateProvider: { self.date }
)

assertHeartbeatControllerFlushesEmptyPayload(controller)

// When
controller.log("dummy_agent")
let heartbeatPayload = await controller.flushAsync()

// Then
try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
heartbeatPayload.headerValue(),
"""
{
"version": 2,
"heartbeats": [
{
"agent": "dummy_agent",
"dates": ["2021-11-01"]
}
]
}
"""
)

assertHeartbeatControllerFlushesEmptyPayload(controller)
}

func testLogAtEndOfTimePeriodAndAcceptAtStartOfNextOne() throws {
// Given
var testDate = date
Expand Down Expand Up @@ -404,4 +437,15 @@ private class HeartbeatStorageFake: HeartbeatStorageProtocol {
heartbeatsBundle = transform(heartbeatsBundle)
return oldHeartbeatsBundle
}

func getAndSetAsync(using transform: @escaping (FirebaseCoreInternal.HeartbeatsBundle?)
-> FirebaseCoreInternal.HeartbeatsBundle?,
completion: @escaping (Result<
FirebaseCoreInternal.HeartbeatsBundle?,
any Error
>) -> Void) {
let oldHeartbeatsBundle = heartbeatsBundle
heartbeatsBundle = transform(heartbeatsBundle)
completion(.success(oldHeartbeatsBundle))
}
}
130 changes: 127 additions & 3 deletions FirebaseCore/Internal/Tests/Unit/HeartbeatStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,46 @@ class HeartbeatStorageTests: XCTestCase {
wait(for: [expectation], timeout: 0.5)
}

func testGetAndSetAsync_ReturnsOldValueAndSetsNewValue() throws {
// Given
let heartbeatStorage = HeartbeatStorage(id: #function, storage: StorageFake())

var dummyHeartbeatsBundle = HeartbeatsBundle(capacity: 1)
dummyHeartbeatsBundle.append(Heartbeat(agent: "dummy_agent", date: Date()))

// When
let expectation1 = expectation(description: #function + "_1")
heartbeatStorage.getAndSetAsync { heartbeatsBundle in
// Assert that heartbeat storage is empty.
XCTAssertNil(heartbeatsBundle)
// Write new value.
return dummyHeartbeatsBundle
} completion: { result in
switch result {
case .success: break
case let .failure(error): XCTFail("Error: \(error)")
}
expectation1.fulfill()
}

// Then
let expectation2 = expectation(description: #function + "_2")
XCTAssertNoThrow(
try heartbeatStorage.getAndSet { heartbeatsBundle in
// Assert old value is read.
XCTAssertEqual(
heartbeatsBundle?.makeHeartbeatsPayload(),
dummyHeartbeatsBundle.makeHeartbeatsPayload()
)
// Write some new value.
expectation2.fulfill()
return heartbeatsBundle
}
)

wait(for: [expectation1, expectation2], timeout: 0.5, enforceOrder: true)
}

func testGetAndSet_WhenLoadFails_PassesNilToBlockAndReturnsNil() throws {
// Given
let expectation = expectation(description: #function)
Expand All @@ -232,6 +272,41 @@ class HeartbeatStorageTests: XCTestCase {
wait(for: [expectation], timeout: 0.5)
}

func testGetAndSetAsync_WhenLoadFails_PassesNilToBlockAndReturnsNil() throws {
// Given
let readExpectation = expectation(description: #function + "_1")
let transformExpectation = expectation(description: #function + "_2")
let completionExpectation = expectation(description: #function + "_3")

let storageFake = StorageFake()
let heartbeatStorage = HeartbeatStorage(id: #function, storage: storageFake)

// When
storageFake.onRead = {
readExpectation.fulfill()
return try XCTUnwrap("BAD_DATA".data(using: .utf8))
}

// Then
heartbeatStorage.getAndSetAsync { heartbeatsBundle in
XCTAssertNil(heartbeatsBundle)
transformExpectation.fulfill()
return heartbeatsBundle
} completion: { result in
switch result {
case .success: break
case let .failure(error): XCTFail("Error: \(error)")
}
completionExpectation.fulfill()
}

wait(
for: [readExpectation, transformExpectation, completionExpectation],
timeout: 0.5,
enforceOrder: true
)
}

func testGetAndSet_WhenSaveFails_ThrowsError() throws {
// Given
let expectation = expectation(description: #function)
Expand All @@ -250,7 +325,42 @@ class HeartbeatStorageTests: XCTestCase {
wait(for: [expectation], timeout: 0.5)
}

func testOperationsAreSynrononizedSerially() throws {
func testGetAndSetAsync_WhenSaveFails_ThrowsError() throws {
// Given
let transformExpectation = expectation(description: #function + "_1")
let writeExpectation = expectation(description: #function + "_2")
let completionExpectation = expectation(description: #function + "_3")

let storageFake = StorageFake()
let heartbeatStorage = HeartbeatStorage(id: #function, storage: storageFake)

// When
storageFake.onWrite = { _ in
writeExpectation.fulfill()
throw StorageError.writeError
}

// Then
heartbeatStorage.getAndSetAsync { heartbeatsBundle in
transformExpectation.fulfill()
XCTAssertNil(heartbeatsBundle)
return heartbeatsBundle
} completion: { result in
switch result {
case .success: XCTFail("Error: unexpected success")
case .failure: break
}
completionExpectation.fulfill()
}

wait(
for: [transformExpectation, writeExpectation, completionExpectation],
timeout: 0.5,
enforceOrder: true
)
}

func testOperationsAreSyncrononizedSerially() throws {
// Given
let heartbeatStorage = HeartbeatStorage(id: #function, storage: StorageFake())

Expand All @@ -263,10 +373,24 @@ class HeartbeatStorageTests: XCTestCase {
return heartbeatsBundle
}

if /* randomChoice */ .random() {
switch Int.random(in: 1 ... 3) {
case 1:
heartbeatStorage.readAndWriteAsync(using: transform)
} else {
case 2:
XCTAssertNoThrow(try heartbeatStorage.getAndSet(using: transform))
case 3:
let getAndSet = self.expectation(description: "GetAndSetAsync_\(i)")
heartbeatStorage.getAndSetAsync(using: transform) { result in
switch result {
case .success: break
case let .failure(error):
XCTFail("Unexpected: Error occurred in getAndSet_\(i), \(error)")
}
getAndSet.fulfill()
}
wait(for: [getAndSet], timeout: 1.0)
default:
XCTFail("Unexpected: Random number is out of range.")
}

return expectation
Expand Down
Loading