From 83522a1e267a57babb200bea236b75eff33834e6 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 8 Oct 2026 13:00:16 -0700 Subject: [PATCH 1/2] feat: [SDK-5376] persistent queue and retry for sessions API requests Co-Authored-By: Claude Opus 5.5 --- .../OneSignal.xcodeproj/project.pbxproj | 12 + .../Source/OneSignalCommonDefines.h | 1 + .../Source/OSSessionRequest.swift | 122 ++++ .../Source/OSSessionRequestQueue.swift | 454 +++++++++++++++ .../Source/OSSessionService.swift | 69 ++- .../Source/OSSessionsBackendService.swift | 7 +- .../OSSessionRequestQueueTests.swift | 545 ++++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 1 + 8 files changed, 1198 insertions(+), 13 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 20cca93f3..373a5df89 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -96,11 +96,14 @@ A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */; }; A5162F01A1B2C3D4E5F60014 /* OSSessionsRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */; }; A5162F01A1B2C3D4E5F60012 /* OSSessionsBackendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */; }; + A5162F01A1B2C3D4E5F60018 /* OSSessionRequestQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60017 /* OSSessionRequestQueue.swift */; }; + A5162F01A1B2C3D4E5F6001C /* OSSessionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F6001B /* OSSessionRequest.swift */; }; A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */; }; A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */; }; A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */; }; A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */; }; + A5162F01A1B2C3D4E5F6001A /* OSSessionRequestQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1914,11 +1917,14 @@ A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionService.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsRequests.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsBackendService.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F60017 /* OSSessionRequestQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequestQueue.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F6001B /* OSSessionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequest.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuard.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuardTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionServiceTests.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsBackendServiceTests.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequestQueueTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; @@ -2426,6 +2432,8 @@ A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */, A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */, + A5162F01A1B2C3D4E5F60017 /* OSSessionRequestQueue.swift */, + A5162F01A1B2C3D4E5F6001B /* OSSessionRequest.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2756,6 +2764,7 @@ A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */, + A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4678,6 +4687,8 @@ A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */, A5162F01A1B2C3D4E5F60014 /* OSSessionsRequests.swift in Sources */, A5162F01A1B2C3D4E5F60012 /* OSSessionsBackendService.swift in Sources */, + A5162F01A1B2C3D4E5F60018 /* OSSessionRequestQueue.swift in Sources */, + A5162F01A1B2C3D4E5F6001C /* OSSessionRequest.swift in Sources */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4876,6 +4887,7 @@ A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */, + A5162F01A1B2C3D4E5F6001A /* OSSessionRequestQueueTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 73c45b635..35c3f2e0e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -113,6 +113,7 @@ #define OSUD_UNSENT_ACTIVE_TIME @"GT_UNSENT_ACTIVE_TIME" // * OSUD_UNSENT_ACTIVE_TIME #define OSUD_UNSENT_ACTIVE_TIME_ATTRIBUTED @"GT_UNSENT_ACTIVE_TIME_ATTRIBUTED" // * OSUD_UNSENT_ACTIVE_TIME_ATTRIBUTED #define OSUD_SESSION_RECORD @"OSUD_SESSION_RECORD" +#define OSUD_SESSION_REQUEST_QUEUE @"OSUD_SESSION_REQUEST_QUEUE" // To avoid undefined symbol compiler errors on older versions of Xcode, // instead of using UNAuthorizationOptionProvisional directly, we will use diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift new file mode 100644 index 000000000..a02bcc770 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift @@ -0,0 +1,122 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import Network + +struct OSSessionRequest: Codable, Equatable { + enum Kind: Codable, Equatable { + /// `startTime` is seconds since 1970. + case create(startTime: TimeInterval, directAttributionId: String?) + /// `activeDuration` is cumulative for the session. `endTime` is seconds since 1970, set + /// only on the update that ends the session. + case update(activeDuration: TimeInterval, endTime: TimeInterval?) + } + + let appId: String + /// `OSSessionRecord.sessionId`, which stands in for the backend session ID until the create succeeds. + let localSessionId: String + /// Set on an update once its session's create succeeds. + var serverSessionId: String? + let identityModelId: String? + var onesignalId: String? + var subscriptionId: String? + var kind: Kind + /// Generated once and persisted, so every retry, including after a restart, is deduplicated by the backend. + let idempotencyKey: String + /// Failed attempts the backend answered, persisted so the limit holds across restarts. + var failedAttempts = 0 + + var isCreate: Bool { + if case .create = kind { + return true + } + return false + } + + var isEnd: Bool { + if case .update(_, let endTime) = kind { + return endTime != nil + } + return false + } + + var activeDuration: TimeInterval? { + if case .update(let activeDuration, _) = kind { + return activeDuration + } + return nil + } +} + +protocol OSSessionNetworkMonitoring: AnyObject { + func start(onAvailable: @escaping () -> Void) + func stop() +} + +final class OSSessionNetworkMonitor: OSSessionNetworkMonitoring { + private var monitor: NWPathMonitor? + + func start(onAvailable: @escaping () -> Void) { + guard monitor == nil else { + return + } + let monitor = NWPathMonitor() + var wasSatisfied: Bool? + monitor.pathUpdateHandler = { path in + let isSatisfied = path.status == .satisfied + defer { wasSatisfied = isSatisfied } + if isSatisfied, wasSatisfied == false { + onAvailable() + } + } + monitor.start(queue: DispatchQueue(label: "OneSignal.OSSessionNetworkMonitor")) + self.monitor = monitor + } + + func stop() { + monitor?.cancel() + monitor = nil + } + + deinit { + monitor?.cancel() + } +} + +extension OSSessionsApiResult { + func map(_ transform: (Value) -> T) -> OSSessionsApiResult { + switch self { + case .success(let value): + return .success(transform(value)) + case .retry(let statusCode, let retryAfterSeconds): + return .retry(statusCode: statusCode, retryAfterSeconds: retryAfterSeconds) + case .drop(let statusCode): + return .drop(statusCode: statusCode) + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift new file mode 100644 index 000000000..799ae8a4c --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift @@ -0,0 +1,454 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore + +/// Persisted queue of sessions API requests. A request is saved before it is sent and removed only +/// once the backend accepts it or rejects it for good, so it survives restarts and offline periods. +/// Requests for a session go out in order, one at a time across the queue. +@objc(OSSessionRequestQueue) +public final class OSSessionRequestQueue: NSObject { + static let maxQueuedRequests = 100 + static let maxFailedAttempts = 5 + static let baseBackoffSeconds: TimeInterval = 5 + static let maxBackoffSeconds: TimeInterval = 300 + + private static let lock = NSLock() + private static var _shared: OSSessionRequestQueue? + + public static var shared: OSSessionRequestQueue { + lock.withLock { + if let existing = _shared { + return existing + } + let created = OSSessionRequestQueue() + _shared = created + return created + } + } + + @objc public static func reset() { + lock.withLock { _shared = nil } + } + + private let storage: OneSignalUserDefaults + private let backend: OSSessionsBackend + private let sessionService: () -> OSSessionService + private let appId: () -> String? + private let networkMonitor: OSSessionNetworkMonitoring + private let now: () -> TimeInterval + private let jitter: () -> Double + private let schedule: (TimeInterval, @escaping () -> Void) -> Void + + // Serial. Synchronizes all state below. + private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSessionRequestQueue", target: .global()) + + private var requests: [OSSessionRequest] = [] + private var inFlightKey: String? + /// Lets an update enqueued while its create was in flight pick up the backend session ID. + private var serverSessionIds: [String: String] = [:] + private var consecutiveFailures = 0 + private var isBackingOff = false + private var backoffGeneration = 0 + /// A backend `Retry-After` that `retryNow` must not cut short. + private var notBefore: TimeInterval = 0 + + init( + storage: OneSignalUserDefaults = .initStandard(), + backend: OSSessionsBackend = OSSessionsBackendService(), + sessionService: @escaping () -> OSSessionService = { .shared }, + appId: @escaping () -> String? = { OneSignalIdentifiers.currentAppId }, + networkMonitor: OSSessionNetworkMonitoring = OSSessionNetworkMonitor(), + now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, + jitter: @escaping () -> Double = { Double.random(in: 0.5...1) }, + schedule: ((TimeInterval, @escaping () -> Void) -> Void)? = nil + ) { + self.storage = storage + self.backend = backend + self.sessionService = sessionService + self.appId = appId + self.networkMonitor = networkMonitor + self.now = now + self.jitter = jitter + let dispatchQueue = self.dispatchQueue + self.schedule = schedule ?? { delay, block in + dispatchQueue.asyncAfter(deadline: .now() + delay, execute: block) + } + super.init() + dispatchQueue.async { + self.load() + self.updateNetworkMonitoring() + } + } +} + +// MARK: - Enqueue + +extension OSSessionRequestQueue { + func enqueueCreate(record: OSSessionRecord, directAttributionId: String? = nil) { + guard record.usesSessionsApi, let appId = appId() else { + return + } + let request = OSSessionRequest( + appId: appId, + localSessionId: record.sessionId, + serverSessionId: nil, + identityModelId: record.identityModelId, + onesignalId: record.onesignalId, + subscriptionId: record.subscriptionId, + kind: .create(startTime: record.startTime, directAttributionId: directAttributionId), + idempotencyKey: UUID().uuidString + ) + dispatchQueue.async { + let isCreated = record.serverSessionId != nil || self.serverSessionIds[record.sessionId] != nil + guard !isCreated, !self.requests.contains(where: { $0.isCreate && $0.localSessionId == record.sessionId }) else { + return + } + self.append(request) + } + } + + /// Reports the session's cumulative duration, and ends the session when `endTime` is set. + func enqueueUpdate(record: OSSessionRecord, endTime: TimeInterval? = nil) { + guard record.usesSessionsApi, let appId = appId() else { + return + } + var request = OSSessionRequest( + appId: appId, + localSessionId: record.sessionId, + serverSessionId: record.serverSessionId, + identityModelId: record.identityModelId, + onesignalId: record.onesignalId, + subscriptionId: record.subscriptionId, + kind: .update(activeDuration: record.activeDuration, endTime: endTime), + idempotencyKey: UUID().uuidString + ) + dispatchQueue.async { + guard self.admitUpdate(&request) else { + return + } + self.append(request) + } + } + + /// Folds queued, unsent updates for the same session into `request`, which then carries the + /// highest duration. An update that ends the session replaces them the same way. + /// Returns false if `request` should be discarded: its session's end is already queued, or it + /// has no backend session ID and no create remains to supply one. + private func admitUpdate(_ request: inout OSSessionRequest) -> Bool { + let session = requests.filter { $0.localSessionId == request.localSessionId } + request.serverSessionId = request.serverSessionId ?? serverSessionIds[request.localSessionId] + let hasEnd = session.contains { $0.isEnd } + let isOrphaned = request.serverSessionId == nil && !session.contains { $0.isCreate } + if hasEnd || isOrphaned { + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSSessionRequestQueue discarding update for \(request.localSessionId), ended: \(hasEnd), never created: \(isOrphaned)" + ) + return false + } + + let queued = session.filter { !$0.isCreate && $0.idempotencyKey != inFlightKey } + guard !queued.isEmpty else { + return true + } + if case .update(let activeDuration, let endTime) = request.kind { + let highest = queued.compactMap(\.activeDuration).max() ?? activeDuration + request.kind = .update(activeDuration: max(activeDuration, highest), endTime: endTime) + } + let removedKeys = Set(queued.map(\.idempotencyKey)) + requests.removeAll { removedKeys.contains($0.idempotencyKey) } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionRequestQueue coalesced \(queued.count) update(s) for \(request.localSessionId)") + return true + } + + private func append(_ request: OSSessionRequest) { + requests.append(request) + enforceCap() + persist() + updateNetworkMonitoring() + processNext() + } + + private func enforceCap() { + while requests.count > Self.maxQueuedRequests, + let oldest = requests.first(where: { $0.idempotencyKey != inFlightKey }) { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OSSessionRequestQueue more than \(Self.maxQueuedRequests) requests queued, dropping oldest: \(oldest)" + ) + remove(oldest) + } + } + + /// Updates for a session whose create is removed without succeeding can never get a backend + /// session ID, so they go with it. + private func remove(_ request: OSSessionRequest) { + requests.removeAll { $0.idempotencyKey == request.idempotencyKey } + guard request.isCreate else { + return + } + let orphans = requests.filter { $0.localSessionId == request.localSessionId && $0.serverSessionId == nil } + guard !orphans.isEmpty else { + return + } + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OSSessionRequestQueue dropping \(orphans.count) update(s) for session \(request.localSessionId), which was never created" + ) + let orphanKeys = Set(orphans.map(\.idempotencyKey)) + requests.removeAll { orphanKeys.contains($0.idempotencyKey) } + } +} + +// MARK: - Sending + +extension OSSessionRequestQueue { + /// Ends the failure backoff early, but still waits out a backend `Retry-After`. + @objc public func retryNow() { + dispatchQueue.async { + if self.isBackingOff { + let remaining = self.notBefore - self.now() + guard remaining <= 0 else { + self.scheduleRetry(after: remaining) + return + } + self.backoffGeneration += 1 + self.isBackingOff = false + } + self.processNext() + } + } + + private func processNext() { + guard inFlightKey == nil, !isBackingOff else { + return + } + var startedSessions = Set() + for index in requests.indices { + let sessionId = requests[index].localSessionId + // Only the first request for a session may go out, which keeps create, updates, end in order. + guard startedSessions.insert(sessionId).inserted else { + continue + } + if let request = readyRequest(at: index) { + send(request) + return + } + } + } + + private func readyRequest(at index: Int) -> OSSessionRequest? { + var request = requests[index] + if request.onesignalId == nil || request.subscriptionId == nil { + (request.onesignalId, request.subscriptionId) = sessionService().pinnedIds( + sessionId: request.localSessionId, + identityModelId: request.identityModelId, + onesignalId: request.onesignalId, + subscriptionId: request.subscriptionId + ) + if request != requests[index] { + requests[index] = request + persist() + } + } + guard request.onesignalId != nil, request.subscriptionId != nil else { + return nil + } + if !request.isCreate, request.serverSessionId == nil { + return nil + } + return request + } + + private func send(_ request: OSSessionRequest) { + guard let onesignalId = request.onesignalId, let subscriptionId = request.subscriptionId else { + return + } + let backgroundTaskIdentifier = "OSSessionRequestQueue" + UUID().uuidString + let finish: (OSSessionsApiResult) -> Void = { result in + self.dispatchQueue.async { + self.handle(result, for: request) + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + + switch request.kind { + case .create(let startTime, let directAttributionId): + let body = OSCreateSessionRequestBody( + onesignalId: onesignalId, + subscriptionId: subscriptionId, + startTime: Date(timeIntervalSince1970: startTime), + idempotencyKey: request.idempotencyKey, + directAttributionId: directAttributionId + ) + inFlightKey = request.idempotencyKey + OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) + backend.createSession(appId: request.appId, body: body) { finish($0.map { $0 }) } + case .update(let activeDuration, let endTime): + guard let serverSessionId = request.serverSessionId else { + return + } + let body = OSUpdateSessionRequestBody( + onesignalId: onesignalId, + subscriptionId: subscriptionId, + durationSeconds: Int64(activeDuration), + idempotencyKey: request.idempotencyKey, + endTime: endTime.map { Date(timeIntervalSince1970: $0) } + ) + inFlightKey = request.idempotencyKey + OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) + backend.updateSession(appId: request.appId, sessionId: serverSessionId, body: body) { finish($0.map { nil }) } + } + } + + private func handle(_ result: OSSessionsApiResult, for sent: OSSessionRequest) { + inFlightKey = nil + guard let index = requests.firstIndex(where: { $0.idempotencyKey == sent.idempotencyKey }) else { + processNext() + return + } + var request = requests[index] + + switch result { + case .success(let serverSessionId): + consecutiveFailures = 0 + requests.remove(at: index) + if let serverSessionId { + applyServerSessionId(serverSessionId, from: request) + } + case .drop(let statusCode): + consecutiveFailures = 0 + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue request failed with \(statusCode), dropping: \(request)") + remove(request) + case .retry(let statusCode, let retryAfterSeconds): + // 0 means no response, e.g. offline, which must keep retrying until the network returns. + if statusCode != 0 { + request.failedAttempts += 1 + } + if request.failedAttempts >= Self.maxFailedAttempts { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OSSessionRequestQueue request failed \(request.failedAttempts) times, dropping: \(request)" + ) + remove(request) + } else { + requests[index] = request + persist() + backOff(retryAfterSeconds: retryAfterSeconds) + return + } + } + persist() + updateNetworkMonitoring() + processNext() + } + + private func applyServerSessionId(_ serverSessionId: String, from create: OSSessionRequest) { + serverSessionIds[create.localSessionId] = serverSessionId + for index in requests.indices where requests[index].localSessionId == create.localSessionId { + requests[index].serverSessionId = serverSessionId + requests[index].onesignalId = requests[index].onesignalId ?? create.onesignalId + requests[index].subscriptionId = requests[index].subscriptionId ?? create.subscriptionId + } + sessionService().setServerSessionId(serverSessionId, forSessionId: create.localSessionId) + } +} + +// MARK: - Backoff + +extension OSSessionRequestQueue { + private func backOff(retryAfterSeconds: Int?) { + consecutiveFailures += 1 + let exponential = min(Self.maxBackoffSeconds, Self.baseBackoffSeconds * pow(2, Double(consecutiveFailures - 1))) + let retryAfter = TimeInterval(retryAfterSeconds ?? 0) + notBefore = now() + retryAfter + isBackingOff = true + scheduleRetry(after: max(exponential * jitter(), retryAfter)) + } + + private func scheduleRetry(after delay: TimeInterval) { + backoffGeneration += 1 + let generation = backoffGeneration + schedule(delay) { [weak self] in + guard let self else { + return + } + self.dispatchQueue.async { + guard self.backoffGeneration == generation else { + return + } + self.isBackingOff = false + self.processNext() + } + } + } +} + +// MARK: - Storage + +extension OSSessionRequestQueue { + private func load() { + guard let data = storage.getSavedObject(forKey: OSUD_SESSION_REQUEST_QUEUE, defaultValue: nil) as? Data else { + return + } + do { + requests = try JSONDecoder().decode([OSSessionRequest].self, from: data) + } catch { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionRequestQueue failed to read saved requests: \(error)") + } + } + + private func persist() { + guard let data = try? JSONEncoder().encode(requests) else { + return + } + storage.saveObject(forKey: OSUD_SESSION_REQUEST_QUEUE, withValue: data) + } + + /// Only watched while requests are pending, so with the flag off nothing changes. + private func updateNetworkMonitoring() { + if requests.isEmpty { + networkMonitor.stop() + } else { + networkMonitor.start { [weak self] in self?.retryNow() } + } + } +} + +// MARK: - Testing + +extension OSSessionRequestQueue { + func waitUntilIdle() { + dispatchQueue.sync {} + } + + var queuedRequests: [OSSessionRequest] { + dispatchQueue.sync { requests } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index a14c82543..3609ca845 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -116,6 +116,7 @@ public final class OSSessionService: NSObject { @objc public static func onFocus() { shared.onFocus() + OSSessionRequestQueue.shared.retryNow() } @objc public static func onUnfocus() { @@ -154,6 +155,8 @@ public final class OSSessionService: NSObject { public func refreshPinnedIds() { let ids = pinnableIds() stateLock.withLock { fillPinnedIds(ids) } + // Queued session requests wait for these IDs, including those of an earlier session's user. + OSSessionRequestQueue.shared.retryNow() } /// Leaves any open foreground interval running: the tracker reports focus before the session @@ -228,22 +231,16 @@ public final class OSSessionService: NSObject { } /// A session that starts before its user is created has no IDs to pin yet. Fill them in once - /// the backend assigns them, but only for the session's own user. The push subscription is - /// carried across logins, but one first created after a login belongs to the new user, unless - /// that login identified the same backend user. + /// the backend assigns them, but only for the session's own user. private func fillPinnedIds(_ ids: PinnableIds?) { guard let ids, var current = record, current.identityModelId == ids.identityModelId else { return } - if current.onesignalId == nil { - current.onesignalId = ids.onesignalId - } - let currentUser = ids.currentUser - let isSameUser = currentUser.identityModelId == ids.identityModelId - || (currentUser.onesignalId != nil && currentUser.onesignalId == current.onesignalId) - if current.subscriptionId == nil, isSameUser { - current.subscriptionId = currentUser.pushSubscriptionId - } + (current.onesignalId, current.subscriptionId) = Self.filling( + onesignalId: current.onesignalId, + subscriptionId: current.subscriptionId, + with: ids + ) guard current != record else { return } @@ -251,6 +248,54 @@ public final class OSSessionService: NSObject { persist() } + /// The push subscription is carried across logins, but one first created after a login belongs + /// to the new user, unless that login identified the same backend user. + private static func filling( + onesignalId: String?, + subscriptionId: String?, + with ids: PinnableIds + ) -> (onesignalId: String?, subscriptionId: String?) { + let onesignalId = onesignalId ?? ids.onesignalId + let currentUser = ids.currentUser + let isSameUser = currentUser.identityModelId == ids.identityModelId + || (currentUser.onesignalId != nil && currentUser.onesignalId == onesignalId) + let subscriptionId = subscriptionId ?? (isSameUser ? currentUser.pushSubscriptionId : nil) + return (onesignalId, subscriptionId) + } + + /// The pinned IDs for a queued request. The current session's come from its record, so the + /// request and the record always agree. An earlier session's are filled by the same rules. + func pinnedIds( + sessionId: String, + identityModelId: String?, + onesignalId: String?, + subscriptionId: String? + ) -> (onesignalId: String?, subscriptionId: String?) { + if let record = currentRecord, record.sessionId == sessionId { + return (onesignalId ?? record.onesignalId, subscriptionId ?? record.subscriptionId) + } + guard let identityModelId, let provider = stateLock.withLock({ userProvider }) else { + return (onesignalId, subscriptionId) + } + let ids = PinnableIds( + identityModelId: identityModelId, + onesignalId: provider.sessionOnesignalId(identityModelId: identityModelId), + currentUser: provider.sessionCurrentUser + ) + return Self.filling(onesignalId: onesignalId, subscriptionId: subscriptionId, with: ids) + } + + func setServerSessionId(_ serverSessionId: String, forSessionId sessionId: String) { + stateLock.withLock { + loadIfNeeded() + guard record?.sessionId == sessionId, record?.serverSessionId == nil else { + return + } + record?.serverSessionId = serverSessionId + persist() + } + } + private func loadIfNeeded() { guard !loaded else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift index e9d35944e..049eb3b48 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift @@ -73,8 +73,13 @@ enum OSSessionsApiResult { extension OSSessionsApiResult: Equatable where Value: Equatable {} +protocol OSSessionsBackend { + func createSession(appId: String, body: OSCreateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) + func updateSession(appId: String, sessionId: String, body: OSUpdateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) +} + /// Typed client for the sessions API. -final class OSSessionsBackendService { +final class OSSessionsBackendService: OSSessionsBackend { static let defaultRetryAfterSeconds = 60 private let client: () -> IOneSignalClient diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift new file mode 100644 index 000000000..e5bd14af3 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift @@ -0,0 +1,545 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import OneSignalKMP +@testable import OneSignalOSCore +import XCTest + +private final class FakeUserProvider: OSSessionUserProvider { + var identityModelId: String? = "model-a" + var onesignalIds: [String: String] = ["model-a": "onesignal-a"] + var pushSubscriptionId: String? = "subscription-a" + + var sessionCurrentUser: OSSessionUser { + OSSessionUser( + identityModelId: identityModelId, + onesignalId: identityModelId.flatMap { onesignalIds[$0] }, + pushSubscriptionId: pushSubscriptionId + ) + } + + func sessionOnesignalId(identityModelId: String) -> String? { + onesignalIds[identityModelId] + } +} + +private final class FakeBackend: OSSessionsBackend { + enum Call { + case create(appId: String, body: OSCreateSessionRequestBody, completion: (OSSessionsApiResult) -> Void) + case update(appId: String, sessionId: String, body: OSUpdateSessionRequestBody, completion: (OSSessionsApiResult) -> Void) + + var idempotencyKey: String { + switch self { + case .create(_, let body, _): return body.idempotencyKey + case .update(_, _, let body, _): return body.idempotencyKey + } + } + } + + private let lock = NSLock() + private var _calls: [Call] = [] + var calls: [Call] { lock.withLock { _calls } } + + func createSession(appId: String, body: OSCreateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) { + lock.withLock { _calls.append(.create(appId: appId, body: body, completion: completion)) } + } + + func updateSession( + appId: String, + sessionId: String, + body: OSUpdateSessionRequestBody, + completion: @escaping (OSSessionsApiResult) -> Void + ) { + lock.withLock { _calls.append(.update(appId: appId, sessionId: sessionId, body: body, completion: completion)) } + } + + func respondToLast(createdSessionId: String) { + guard case .create(_, _, let completion) = calls.last else { + return XCTFail("last call is not a create") + } + completion(.success(createdSessionId)) + } + + func respondToLastUpdate() { + guard case .update(_, _, _, let completion) = calls.last else { + return XCTFail("last call is not an update") + } + completion(.success(())) + } + + func failLast(_ result: OSSessionsApiResult) { + switch calls.last { + case .create(_, _, let completion): + switch result { + case .retry(let statusCode, let retryAfterSeconds): + completion(.retry(statusCode: statusCode, retryAfterSeconds: retryAfterSeconds)) + case .drop(let statusCode): + completion(.drop(statusCode: statusCode)) + case .success: + XCTFail("not a failure") + } + case .update(_, _, _, let completion): + completion(result) + case nil: + XCTFail("no call") + } + } +} + +private final class FakeNetworkMonitor: OSSessionNetworkMonitoring { + private(set) var onAvailable: (() -> Void)? + + func start(onAvailable: @escaping () -> Void) { + self.onAvailable = onAvailable + } + + func stop() { + onAvailable = nil + } +} + +final class OSSessionRequestQueueTests: XCTestCase { + private var user: FakeUserProvider! + private var backend: FakeBackend! + private var monitor: FakeNetworkMonitor! + private var service: OSSessionService! + private var flagsStore: OSFeatureFlagsStore! + private var now: TimeInterval = 10_000 + private var scheduled: [(delay: TimeInterval, block: () -> Void)] = [] + private var queue: OSSessionRequestQueue! + + override func setUp() { + super.setUp() + clearStorage() + user = FakeUserProvider() + backend = FakeBackend() + monitor = FakeNetworkMonitor() + flagsStore = OSFeatureFlagsStore() + flagsStore.applyRemoteFlags([FeatureFlag.sdkSessionsV2ApiCutover.key], metadata: nil) + let manager = OSFeatureManager(store: flagsStore) + service = OSSessionService(featureManager: { manager }, monotonicNow: { 0 }, wallNow: { 1_700_000_000 }) + now = 10_000 + scheduled = [] + queue = makeQueue() + } + + override func tearDown() { + queue?.waitUntilIdle() + clearStorage() + super.tearDown() + } + + private func clearStorage() { + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_REQUEST_QUEUE) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + } + + private func makeQueue() -> OSSessionRequestQueue { + OSSessionRequestQueue( + backend: backend, + sessionService: { [unowned self] in self.service }, + appId: { "app-id" }, + networkMonitor: monitor, + now: { [unowned self] in self.now }, + jitter: { 1 }, + schedule: { [unowned self] delay, block in self.scheduled.append((delay, block)) } + ) + } + + private func startSession() -> OSSessionRecord { + service.startNewSession(userProvider: user) + return service.currentRecord! + } + + private func record(duration: TimeInterval, from base: OSSessionRecord) -> OSSessionRecord { + var record = base + record.activeDuration = duration + return record + } + + private func settle() { + queue.waitUntilIdle() + queue.waitUntilIdle() + } + + private var updateDurations: [Int64] { + backend.calls.compactMap { + if case .update(_, _, let body, _) = $0 { + return body.durationSeconds + } + return nil + } + } + + // MARK: - Sending + + func testCreateIsPersistedBeforeSendingAndRemovedOnSuccess() { + let record = startSession() + + queue.enqueueCreate(record: record, directAttributionId: "notification-id") + settle() + + XCTAssertEqual(queue.queuedRequests.count, 1) + XCTAssertNotNil(OneSignalUserDefaults.initStandard().getSavedObject(forKey: OSUD_SESSION_REQUEST_QUEUE, defaultValue: nil)) + guard case .create(let appId, let body, _) = backend.calls.first else { + return XCTFail("expected a create") + } + XCTAssertEqual(appId, "app-id") + XCTAssertEqual(body.onesignalId, "onesignal-a") + XCTAssertEqual(body.subscriptionId, "subscription-a") + XCTAssertEqual(body.startTime, Date(timeIntervalSince1970: 1_700_000_000)) + XCTAssertEqual(body.directAttributionId, "notification-id") + + backend.respondToLast(createdSessionId: "server-1") + settle() + + XCTAssertTrue(queue.queuedRequests.isEmpty) + XCTAssertEqual(service.currentRecord?.serverSessionId, "server-1") + } + + func testFlagOffEnqueuesNothing() { + flagsStore.applyRemoteFlags([], metadata: nil) + let record = startSession() + + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: record) + settle() + + XCTAssertTrue(queue.queuedRequests.isEmpty) + XCTAssertTrue(backend.calls.isEmpty) + } + + func testCreateWaitsForOnesignalIdAndSubscriptionId() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + let record = startSession() + + queue.enqueueCreate(record: record) + settle() + XCTAssertTrue(backend.calls.isEmpty) + + user.onesignalIds = ["model-a": "onesignal-a"] + queue.retryNow() + settle() + XCTAssertTrue(backend.calls.isEmpty) + + user.pushSubscriptionId = "subscription-a" + queue.retryNow() + settle() + + guard case .create(_, let body, _) = backend.calls.first else { + return XCTFail("expected a create") + } + XCTAssertEqual(body.onesignalId, "onesignal-a") + XCTAssertEqual(body.subscriptionId, "subscription-a") + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + } + + func testUpdateWaitsForCreateAndUsesServerSessionId() { + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 12.9, from: record)) + settle() + XCTAssertEqual(backend.calls.count, 1) + + backend.respondToLast(createdSessionId: "server-1") + settle() + + guard case .update(_, let sessionId, let body, _) = backend.calls.last else { + return XCTFail("expected an update") + } + XCTAssertEqual(sessionId, "server-1") + XCTAssertEqual(body.durationSeconds, 12) + XCTAssertNil(body.endTime) + + backend.respondToLastUpdate() + settle() + XCTAssertTrue(queue.queuedRequests.isEmpty) + } + + func testUpdateEnqueuedWhileCreateInFlightGetsServerSessionId() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + + // The caller still holds the record read before the create returned. + queue.enqueueUpdate(record: self.record(duration: 5, from: record)) + settle() + + guard case .update(_, let sessionId, _, _) = backend.calls.last else { + return XCTFail("expected an update") + } + XCTAssertEqual(sessionId, "server-1") + } + + func testCreateIsNotEnqueuedTwice() { + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueCreate(record: record) + settle() + XCTAssertEqual(queue.queuedRequests.count, 1) + + backend.respondToLast(createdSessionId: "server-1") + settle() + queue.enqueueCreate(record: record) + settle() + + XCTAssertEqual(backend.calls.count, 1) + } + + // MARK: - Coalescing + + func testQueuedUpdatesCollapseToHighestDuration() { + user.onesignalIds = [:] + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 30, from: record)) + queue.enqueueUpdate(record: self.record(duration: 20, from: record)) + settle() + + let requests = queue.queuedRequests + XCTAssertEqual(requests.count, 2) + XCTAssertEqual(requests.last?.activeDuration, 30) + } + + func testEndReplacesQueuedUpdateAndLaterUpdatesAreDiscarded() { + user.onesignalIds = [:] + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + queue.enqueueUpdate(record: self.record(duration: 15, from: record), endTime: 1_700_000_100) + queue.enqueueUpdate(record: self.record(duration: 20, from: record)) + settle() + + let requests = queue.queuedRequests + XCTAssertEqual(requests.count, 2) + XCTAssertEqual(requests.last?.kind, .update(activeDuration: 15, endTime: 1_700_000_100)) + } + + func testInFlightUpdateIsNotCoalesced() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + + queue.enqueueUpdate(record: self.record(duration: 20, from: record)) + settle() + XCTAssertEqual(queue.queuedRequests.count, 2) + + backend.respondToLastUpdate() + settle() + + XCTAssertEqual(updateDurations, [10, 20]) + } + + func testUpdateWithoutCreateOrServerSessionIdIsDiscarded() { + let record = startSession() + + queue.enqueueUpdate(record: record) + settle() + + XCTAssertTrue(queue.queuedRequests.isEmpty) + } + + func testSessionsAreCoalescedIndependently() { + user.onesignalIds = [:] + let first = startSession() + queue.enqueueCreate(record: first) + queue.enqueueUpdate(record: record(duration: 10, from: first)) + let second = startSession() + queue.enqueueCreate(record: second) + queue.enqueueUpdate(record: record(duration: 5, from: second)) + settle() + + XCTAssertEqual(queue.queuedRequests.count, 4) + } + + // MARK: - Cap + + func testCapDropsOldestAndItsUpdates() { + user.onesignalIds = [:] + let first = startSession() + queue.enqueueCreate(record: first) + queue.enqueueUpdate(record: record(duration: 10, from: first)) + for _ in 0.. Date: Thu, 8 Oct 2026 14:39:27 -0700 Subject: [PATCH 2/2] fix: [SDK-5376] address session queue review feedback Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionRequest.swift | 6 + .../Source/OSSessionRequestQueue.swift | 94 ++++++++++++-- .../Source/OSSessionService.swift | 10 ++ .../Source/OSSessionsRequests.swift | 10 ++ .../OSSessionRequestQueueTests.swift | 117 ++++++++++++++++++ .../OSSessionServiceTests.swift | 6 + .../OSSessionsBackendServiceTests.swift | 10 ++ .../OneSignalUserManagerImpl+Session.swift | 6 + iOS_SDK/OneSignalSDK/Source/OneSignal.m | 1 + 9 files changed, 247 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift index a02bcc770..fdc75913c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift @@ -65,6 +65,12 @@ struct OSSessionRequest: Codable, Equatable { return false } + /// Leaves out the user and subscription IDs, since WARN logs can be uploaded. + var logDescription: String { + let kindName = isCreate ? "create" : (isEnd ? "end" : "update") + return "\(kindName) for session \(localSessionId), failed attempts: \(failedAttempts)" + } + var activeDuration: TimeInterval? { if case .update(let activeDuration, _) = kind { return activeDuration diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift index 799ae8a4c..a7d29fca2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift @@ -53,7 +53,18 @@ public final class OSSessionRequestQueue: NSObject { } @objc public static func reset() { - lock.withLock { _shared = nil } + let retired = lock.withLock { + let existing = _shared + _shared = nil + return existing + } + retired?.retire() + } + + /// Saved requests carry the previous app's ID, so they must not outlive an app-id change. + @objc public static func resetAndClearStoredQueue() { + reset() + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_REQUEST_QUEUE) } private let storage: OneSignalUserDefaults @@ -75,8 +86,10 @@ public final class OSSessionRequestQueue: NSObject { private var consecutiveFailures = 0 private var isBackingOff = false private var backoffGeneration = 0 - /// A backend `Retry-After` that `retryNow` must not cut short. + /// A backend `Retry-After` that `retryNow` must not cut short, on the `now` clock. private var notBefore: TimeInterval = 0 + /// Set once the queue is replaced, so a late completion cannot write its requests back. + private var isRetired = false init( storage: OneSignalUserDefaults = .initStandard(), @@ -84,7 +97,10 @@ public final class OSSessionRequestQueue: NSObject { sessionService: @escaping () -> OSSessionService = { .shared }, appId: @escaping () -> String? = { OneSignalIdentifiers.currentAppId }, networkMonitor: OSSessionNetworkMonitoring = OSSessionNetworkMonitor(), - now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, + // The same clock as `DispatchTime`, so `notBefore` agrees with the scheduled retry. + now: @escaping () -> TimeInterval = { + TimeInterval(clock_gettime_nsec_np(CLOCK_UPTIME_RAW)) / TimeInterval(NSEC_PER_SEC) + }, jitter: @escaping () -> Double = { Double.random(in: 0.5...1) }, schedule: ((TimeInterval, @escaping () -> Void) -> Void)? = nil ) { @@ -103,6 +119,14 @@ public final class OSSessionRequestQueue: NSObject { dispatchQueue.async { self.load() self.updateNetworkMonitoring() + self.processNext() + } + } + + private func retire() { + dispatchQueue.sync { + isRetired = true + networkMonitor.stop() } } } @@ -122,7 +146,8 @@ extension OSSessionRequestQueue { onesignalId: record.onesignalId, subscriptionId: record.subscriptionId, kind: .create(startTime: record.startTime, directAttributionId: directAttributionId), - idempotencyKey: UUID().uuidString + // Derived from the session, so a create enqueued again after being dropped is still deduplicated. + idempotencyKey: record.sessionId ) dispatchQueue.async { let isCreated = record.serverSessionId != nil || self.serverSessionIds[record.sessionId] != nil @@ -183,6 +208,16 @@ extension OSSessionRequestQueue { } let removedKeys = Set(queued.map(\.idempotencyKey)) requests.removeAll { removedKeys.contains($0.idempotencyKey) } + // An unchanged body keeps its key, so a retry the backend may already have applied stays + // deduplicated. Either way the attempts carry over, or a session that keeps reporting + // would never reach the drop limit. + let failedAttempts = queued.map(\.failedAttempts).max() ?? 0 + if let unchanged = queued.first(where: { $0.kind == request.kind }) { + let serverSessionId = request.serverSessionId + request = unchanged + request.serverSessionId = unchanged.serverSessionId ?? serverSessionId + } + request.failedAttempts = max(request.failedAttempts, failedAttempts) OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionRequestQueue coalesced \(queued.count) update(s) for \(request.localSessionId)") return true } @@ -200,7 +235,7 @@ extension OSSessionRequestQueue { let oldest = requests.first(where: { $0.idempotencyKey != inFlightKey }) { OneSignalLog.onesignalLog( .LL_WARN, - message: "OSSessionRequestQueue more than \(Self.maxQueuedRequests) requests queued, dropping oldest: \(oldest)" + message: "OSSessionRequestQueue more than \(Self.maxQueuedRequests) requests queued, dropping oldest: \(oldest.logDescription)" ) remove(oldest) } @@ -246,6 +281,10 @@ extension OSSessionRequestQueue { } private func processNext() { + guard !isRetired else { + return + } + dropUnsendable() guard inFlightKey == nil, !isBackingOff else { return } @@ -263,6 +302,23 @@ extension OSSessionRequestQueue { } } + /// A create still waiting on a `onesignal_id` its user will never get, which happens to an + /// anonymous user under required Identity Verification. Its updates go with it. + private func dropUnsendable() { + let service = sessionService() + let unsendable = requests.filter { + $0.isCreate && $0.onesignalId == nil && $0.idempotencyKey != inFlightKey + && !service.canCreateUser(identityModelId: $0.identityModelId) + } + guard !unsendable.isEmpty else { + return + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue dropping \(unsendable.count) create(s) for a user that is never created") + unsendable.forEach(remove) + persist() + updateNetworkMonitoring() + } + private func readyRequest(at index: Int) -> OSSessionRequest? { var request = requests[index] if request.onesignalId == nil || request.subscriptionId == nil { @@ -344,7 +400,7 @@ extension OSSessionRequestQueue { } case .drop(let statusCode): consecutiveFailures = 0 - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue request failed with \(statusCode), dropping: \(request)") + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue dropping after a \(statusCode): \(request.logDescription)") remove(request) case .retry(let statusCode, let retryAfterSeconds): // 0 means no response, e.g. offline, which must keep retrying until the network returns. @@ -352,17 +408,20 @@ extension OSSessionRequestQueue { request.failedAttempts += 1 } if request.failedAttempts >= Self.maxFailedAttempts { - OneSignalLog.onesignalLog( - .LL_WARN, - message: "OSSessionRequestQueue request failed \(request.failedAttempts) times, dropping: \(request)" - ) + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue dropping after too many failures: \(request.logDescription)") remove(request) } else { requests[index] = request - persist() + } + persist() + updateNetworkMonitoring() + // A dropped request still honors `Retry-After`, which applies to the next request too. + if request.failedAttempts < Self.maxFailedAttempts || retryAfterSeconds != nil { backOff(retryAfterSeconds: retryAfterSeconds) - return + } else { + processNext() } + return } persist() updateNetworkMonitoring() @@ -419,13 +478,18 @@ extension OSSessionRequestQueue { } do { requests = try JSONDecoder().decode([OSSessionRequest].self, from: data) + for request in requests { + if let serverSessionId = request.serverSessionId { + serverSessionIds[request.localSessionId] = serverSessionId + } + } } catch { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionRequestQueue failed to read saved requests: \(error)") } } private func persist() { - guard let data = try? JSONEncoder().encode(requests) else { + guard !isRetired, let data = try? JSONEncoder().encode(requests) else { return } storage.saveObject(forKey: OSUD_SESSION_REQUEST_QUEUE, withValue: data) @@ -448,6 +512,10 @@ extension OSSessionRequestQueue { dispatchQueue.sync {} } + func retireForTesting() { + retire() + } + var queuedRequests: [OSSessionRequest] { dispatchQueue.sync { requests } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 3609ca845..e1744478e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -48,6 +48,9 @@ public protocol OSSessionUserProvider: AnyObject { /// Taken from one read of the current user, so a concurrent login cannot mix two users. var sessionCurrentUser: OSSessionUser { get } func sessionOnesignalId(identityModelId: String) -> String? + /// False when the user can never get a `onesignal_id`, such as an anonymous user under + /// required Identity Verification. + func sessionUserCanBeCreated(identityModelId: String) -> Bool } /// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized @@ -285,6 +288,13 @@ public final class OSSessionService: NSObject { return Self.filling(onesignalId: onesignalId, subscriptionId: subscriptionId, with: ids) } + func canCreateUser(identityModelId: String?) -> Bool { + guard let identityModelId, let provider = stateLock.withLock({ userProvider }) else { + return true + } + return provider.sessionUserCanBeCreated(identityModelId: identityModelId) + } + func setServerSessionId(_ serverSessionId: String, forSessionId sessionId: String) { stateLock.withLock { loadIfNeeded() diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift index a861a09e9..6500d3604 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift @@ -41,6 +41,14 @@ enum OSSessionTimestamp { } } +private extension OneSignalRequest { + /// `OSSessionRequestQueue` owns retries, with backoff that honors `Retry-After`. Starting at the + /// client's last attempt stops `OneSignalClient` from retrying status 0 and 5xx underneath it. + func skipClientRetries() { + reattemptCount = MAX_ATTEMPT_COUNT - 1 + } +} + /// `POST apps/{app_id}/sessions` final class OSRequestCreateSession: OneSignalRequest { override var description: String { @@ -61,6 +69,7 @@ final class OSRequestCreateSession: OneSignalRequest { } self.parameters = parameters self.method = POST + skipClientRetries() self.path = "apps/\(appId)/sessions" } } @@ -84,6 +93,7 @@ final class OSRequestUpdateSession: OneSignalRequest { } self.parameters = parameters self.method = PATCH + skipClientRetries() self.path = "apps/\(appId)/sessions/\(sessionId)" } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift index e5bd14af3..69cadd4cf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift @@ -35,6 +35,7 @@ private final class FakeUserProvider: OSSessionUserProvider { var identityModelId: String? = "model-a" var onesignalIds: [String: String] = ["model-a": "onesignal-a"] var pushSubscriptionId: String? = "subscription-a" + var creatableModelIds: Set? var sessionCurrentUser: OSSessionUser { OSSessionUser( @@ -47,6 +48,10 @@ private final class FakeUserProvider: OSSessionUserProvider { func sessionOnesignalId(identityModelId: String) -> String? { onesignalIds[identityModelId] } + + func sessionUserCanBeCreated(identityModelId: String) -> Bool { + creatableModelIds?.contains(identityModelId) ?? true + } } private final class FakeBackend: OSSessionsBackend { @@ -224,6 +229,64 @@ final class OSSessionRequestQueueTests: XCTestCase { XCTAssertEqual(service.currentRecord?.serverSessionId, "server-1") } + func testCreateIdempotencyKeyIsTheSessionId() { + let record = startSession() + + queue.enqueueCreate(record: record) + settle() + + XCTAssertEqual(backend.calls.first?.idempotencyKey, record.sessionId) + } + + func testRestartRemembersServerSessionIdOfQueuedUpdates() { + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + backend.failLast(.retry(statusCode: 0, retryAfterSeconds: nil)) + settle() + + queue = makeQueue() + // A snapshot taken before the create returned. + queue.enqueueCreate(record: record) + settle() + + XCTAssertFalse(queue.queuedRequests.contains { $0.isCreate }) + } + + func testAppIdChangeClearsStoredQueueAndLateCompletionDoesNotRestoreIt() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + + let retired = queue! + let key = OSUD_SESSION_REQUEST_QUEUE + retired.retireForTesting() + OneSignalUserDefaults.initStandard().removeValue(forKey: key) + backend.failLast(.retry(statusCode: 500, retryAfterSeconds: nil)) + retired.waitUntilIdle() + + XCTAssertNil(OneSignalUserDefaults.initStandard().getSavedObject(forKey: key, defaultValue: nil)) + } + + func testCreateForUserThatIsNeverCreatedIsDroppedWithItsUpdates() { + user.onesignalIds = [:] + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + XCTAssertEqual(queue.queuedRequests.count, 2) + + user.creatableModelIds = [] + queue.retryNow() + settle() + + XCTAssertTrue(queue.queuedRequests.isEmpty) + XCTAssertNil(monitor.onAvailable) + } + func testFlagOffEnqueuesNothing() { flagsStore.applyRemoteFlags([], metadata: nil) let record = startSession() @@ -345,6 +408,30 @@ final class OSSessionRequestQueueTests: XCTestCase { XCTAssertEqual(requests.last?.kind, .update(activeDuration: 15, endTime: 1_700_000_100)) } + func testCoalescingKeepsUnchangedRequestAndCarriesFailedAttempts() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + let key = backend.calls.last?.idempotencyKey + backend.failLast(.retry(statusCode: 500, retryAfterSeconds: nil)) + settle() + + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + XCTAssertEqual(queue.queuedRequests.map(\.idempotencyKey), [key]) + XCTAssertEqual(queue.queuedRequests.first?.failedAttempts, 1) + + queue.enqueueUpdate(record: self.record(duration: 20, from: record)) + settle() + XCTAssertEqual(queue.queuedRequests.count, 1) + XCTAssertNotEqual(queue.queuedRequests.first?.idempotencyKey, key) + XCTAssertEqual(queue.queuedRequests.first?.failedAttempts, 1) + } + func testInFlightUpdateIsNotCoalesced() { let record = startSession() queue.enqueueCreate(record: record) @@ -518,6 +605,36 @@ final class OSSessionRequestQueueTests: XCTestCase { XCTAssertTrue(queue.queuedRequests.isEmpty) } + func testDropAtAttemptLimitStillHonorsRetryAfter() { + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueCreate(record: startSession()) + settle() + for _ in 1.. String? { onesignalIds[identityModelId] } + + func sessionUserCanBeCreated(identityModelId: String) -> Bool { + true + } } final class OSSessionServiceTests: XCTestCase { @@ -71,6 +75,8 @@ final class OSSessionServiceTests: XCTestCase { override func tearDown() { OSSessionService.reset() + // `refreshPinnedIds` wakes the shared queue. + OSSessionRequestQueue.resetAndClearStoredQueue() OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) super.tearDown() diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift index 8fb76ba04..488f005f1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift @@ -123,6 +123,16 @@ final class OSSessionsBackendServiceTests: XCTestCase { ]) } + func testSessionRequestsSkipClientRetries() { + _ = create() + _ = update() + + // `MAX_ATTEMPT_COUNT` depends on `OS_TEST`, which this target and the SDK set differently. + for request in client.requests { + XCTAssertGreaterThan(request.reattemptCount, 0) + } + } + func testCreateSessionIncludesDirectAttributionWhenSet() { client.outcome = .success(["data": ["session_id": "server-id"]]) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index fca1f94d0..96952338e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -41,4 +41,10 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { public func sessionOnesignalId(identityModelId: String) -> String? { identityModelRepo.get(modelId: identityModelId)?.onesignalId } + + /// While the requirement is still unknown the user may yet be created, so only `.on` says no. + public func sessionUserCanBeCreated(identityModelId: String) -> Bool { + identityVerificationService.requirement != .on + || identityModelRepo.get(modelId: identityModelId)?.externalId != nil + } } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 5db98280e..9ddb63b6c 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -758,6 +758,7 @@ + (void)handleAppIdChange:(NSString*)appId { // within a process, so both the cache and the latch have to go. [OSFeatureManager resetAndClearCachedFlags]; [OSSessionService resetAndClearStoredRecord]; + [OSSessionRequestQueue resetAndClearStoredQueue]; let sharedUserDefaults = OneSignalUserDefaults.initShared;