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
4 changes: 4 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
3CA8B8822BEC2FCB0010ADA1 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; };
3CA8B8832BEC2FCB0010ADA1 /* XCTest.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */; };
E85C1C500D87196C5E20144F /* ExecutorReadYourWriteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */; };
3C5181A1B2C3D4E5F6A7B802 /* SubscriptionCreateResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */; };
3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */; };
3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */; };
Expand Down Expand Up @@ -1481,6 +1482,7 @@
3C9AD6D22B228BB000BC1540 /* OSRequestUpdateProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestUpdateProperties.swift; sourceTree = "<group>"; };
3CA6CE0928E4F19B00CA0585 /* OSUserRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserRequest.swift; sourceTree = "<group>"; };
3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionUpdateRaceTests.swift; sourceTree = "<group>"; };
C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutorReadYourWriteTests.swift; sourceTree = "<group>"; };
3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionCreateResponseTests.swift; sourceTree = "<group>"; };
3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionModelConcurrencyTests.swift; sourceTree = "<group>"; };
3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2552,6 +2554,7 @@
children = (
3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */,
3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */,
C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */,
3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */,
3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */,
FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */,
Expand Down Expand Up @@ -4714,6 +4717,7 @@
3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */,
3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */,
3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */,
E85C1C500D87196C5E20144F /* ExecutorReadYourWriteTests.swift in Sources */,
3C5181A1B2C3D4E5F6A7B802 /* SubscriptionCreateResponseTests.swift in Sources */,
3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */,
D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ import OneSignalCore
@objc public static let shared = OSConsistencyManager()

// Serial, and the only place `indexedTokens` and `indexedConditions` may be touched.
// Non-private so test helpers can synchronize with it.
// Neither is private, so test helpers can read them through it.
let queue = DispatchQueue(label: "com.consistencyManager.queue")
private var indexedTokens: [String: [NSNumber: OSReadYourWriteData]] = [:]
// Waiters, indexed by the id passed to getRywTokenFromAwaitableCondition. Non-private for tests.
var indexedTokens: [String: [NSNumber: OSReadYourWriteData]] = [:]
// Waiters, indexed by the id passed to getRywTokenFromAwaitableCondition.
var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:]

/**
Expand All @@ -58,7 +58,12 @@ import OneSignalCore
}
}

// Function to set the token in a thread-safe manner
/**
Records the outcome of a write for `id` under the caller's key, and releases any waiter it satisfies.
A response with no `ryw_token` is filed as a blank entry: the write still completed, and a condition
that only needs it to have completed is met by the entry being there. A fetch that registers later
finds it on file, instead of waiting the full timeout for a token that is never coming.
*/
public func setRywTokenAndDelay(id: String, key: any OSConsistencyKeyEnum, value: OSReadYourWriteData) {
queue.sync {
let nsKey = NSNumber(value: key.rawValue)
Expand All @@ -73,46 +78,34 @@ import OneSignalCore
/// Blocks the caller until the condition is met or `waitTimeout` elapses, then returns the newest
/// token the condition accepts, which is nil when it was released without one.
@objc public func getRywTokenFromAwaitableCondition(_ condition: OSCondition, forId id: String) -> OSReadYourWriteData? {
// The timeout in force when the waiter registers is the one it waits.
let timeout = OSConsistencyManager.waitTimeout
let semaphore = DispatchSemaphore(value: 0)
queue.sync {
self.indexedConditions[id, default: []].append((condition, semaphore))
self.checkConditionsAndComplete(forId: id)
}
if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .timedOut {
if semaphore.wait(timeout: .now() + timeout) == .timedOut {
OneSignalLog.onesignalLog(.LL_WARN, message: "OSConsistencyManager timed out waiting on \(condition.conditionId) for id: \(id)")
queue.sync {
// Skip if a met-path release already removed this waiter.
guard self.indexedConditions[id]?.contains(where: { $0.1 === semaphore }) == true else {
return
}
// Deregister first, so the re-check below cannot release this waiter a second time.
self.indexedConditions[id]?.removeAll { $0.1 === semaphore }
// Clear so later fetches for this id are not held to a subscription token that never arrives.
condition.onConditionSatisfied?()
self.indexedConditions[id]?.removeAll { $0.1 === semaphore }
// The lowered bar can be all a sibling waiter on this id was missing, so measure the rest
// against it now instead of leaving them to sit out their own timeouts.
self.checkConditionsAndComplete(forId: id)
}
}
return queue.sync {
return condition.getNewestToken(indexedTokens: self.indexedTokens)
}
}

/**
Releases waiters on `conditionId` registered under `id` (e.g. onesignalId). Used when that user's
response carried no `ryw_token`, so those waiters have nothing left to wait for.
*/
@objc(resolveConditionsWithConditionId:forId:)
public func resolveConditions(conditionId: String, forId id: String) {
queue.sync {
guard let waiters = self.indexedConditions[id] else {
return
}
for (condition, semaphore) in waiters where condition.conditionId == conditionId {
OneSignalLog.onesignalLog(.LL_INFO, message: "Condition \(conditionId) resolved for id: \(id)")
self.release(condition, semaphore)
}
self.indexedConditions[id] = waiters.filter { $0.0.conditionId != conditionId }
}
}

// Private method to check conditions for a specific id (unique ID like onesignalId)
private func checkConditionsAndComplete(forId id: String) {
guard let waiters = indexedConditions[id] else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
THE SOFTWARE.
*/

import OneSignalOSCore
@testable import OneSignalOSCore

public class ConsistencyManagerTestHelpers {
/// Clears both halves of the read-your-write state: the manager's tokens and waiters, and the
Expand All @@ -44,3 +44,10 @@ public class ConsistencyManagerTestHelpers {
OSConsistencyManager.shared.setRywTokenAndDelay(id: id, key: key, value: rywData)
}
}

extension OSConsistencyManager {
/// The tokens on file right now, read through the manager's own queue.
public func snapshotTokens() -> [String: [NSNumber: OSReadYourWriteData]] {
return queue.sync { indexedTokens }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -281,59 +281,72 @@ class OSConsistencyManagerTests: XCTestCase {
XCTAssertEqual(rywData?.rywToken, "456")
}

// MARK: - Releasing waiters
// MARK: - Writes that came back without a token

/// A response with no `ryw_token` releases waiters for that user only.
func testResolvingByConditionIdReleasesWaitersForThatId() {
private let blank = OSReadYourWriteData(rywToken: nil, rywDelay: nil)

/// A write that completed with no `ryw_token` is filed like any other, so a waiter that registers
/// afterwards has nothing left to wait for.
func testAWaiterRegisteredAfterATokenlessWriteIsReleasedAtOnce() {
let id = "onesignal-id"
let key = NSNumber(value: OSIamFetchOffsetKey.userUpdate.rawValue)
consistencyManager.setRywTokenAndDelay(id: id, key: OSIamFetchOffsetKey.userUpdate, value: blank)

let condition = TestMetCondition(expectedTokens: [id: [key: blank]])
let returned = expectation(description: "waiter returned")
DispatchQueue.global().async {
_ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id")
_ = self.consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id)
returned.fulfill()
}
OneSignalCoreMocks.waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 }

consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: "onesignal-id")

// The timeout is the full 30 seconds here, so only a release brings the waiter back in time.
wait(for: [returned], timeout: 2.0)
XCTAssertEqual(consistencyManager.waiterCount, 0)
}

/// Resolving user B leaves user A's waiter registered.
func testResolvingOneIdLeavesAnotherIdsWaiterWaiting() {
/// The entry is filed under its id, so user B's tokenless write does not release user A.
func testATokenlessWriteForOneIdLeavesAnotherIdsWaiterWaiting() {
OSConsistencyManager.waitTimeout = .milliseconds(200)
let key = NSNumber(value: OSIamFetchOffsetKey.userUpdate.rawValue)
let userA = "onesignal-id-a"
let userB = "onesignal-id-b"
let conditionA = TestMetCondition(expectedTokens: [userA: [key: blank]])
let conditionB = TestMetCondition(expectedTokens: [userB: [key: blank]])

let aReturned = expectation(description: "user A waiter returned")
let bReturned = expectation(description: "user B waiter returned")
DispatchQueue.global().async {
_ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userA)
_ = self.consistencyManager.getRywTokenFromAwaitableCondition(conditionA, forId: userA)
aReturned.fulfill()
}
DispatchQueue.global().async {
_ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userB)
_ = self.consistencyManager.getRywTokenFromAwaitableCondition(conditionB, forId: userB)
bReturned.fulfill()
}
OneSignalCoreMocks.waitUntil("both waiters registered") { self.consistencyManager.waiterCount == 2 }

consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: userB)
consistencyManager.setRywTokenAndDelay(id: userB, key: OSIamFetchOffsetKey.userUpdate, value: blank)

wait(for: [bReturned], timeout: 2.0)
XCTAssertEqual(consistencyManager.waiterCount, 1, "user A's waiter must still be registered")
// Drain A's timeout so the thread is not left blocked after the test.
wait(for: [aReturned], timeout: 2.0)
}

func testResolvingADifferentConditionLeavesTheWaiterWaiting() {
/// The entry is filed under the writer's key, so a waiter that needs a different write keeps waiting.
func testATokenlessWriteUnderAnotherKeyLeavesTheWaiterWaiting() {
OSConsistencyManager.waitTimeout = .milliseconds(200)
let id = "onesignal-id"
let subscriptionKey = NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue)
let condition = TestMetCondition(expectedTokens: [id: [subscriptionKey: blank]])
let returned = expectation(description: "waiter returned")
DispatchQueue.global().async {
_ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id")
_ = self.consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id)
returned.fulfill()
}
OneSignalCoreMocks.waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 }

consistencyManager.resolveConditions(conditionId: "SomeOtherCondition", forId: "onesignal-id")
consistencyManager.setRywTokenAndDelay(id: id, key: OSIamFetchOffsetKey.userUpdate, value: blank)

XCTAssertEqual(consistencyManager.waiterCount, 1)
// Drain the timeout so the thread is not left blocked after the test.
Expand Down
Loading
Loading