From e3ebd618324503d97d1518eb39319e5cd573ac53 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 14 Oct 2026 16:43:53 -0700 Subject: [PATCH 01/10] feat: [SDK-5379] persistent session requests through the operation repo Session requests are persisted and sent by OSSessionOperationExecutor, with ordering, coalescing, and a size cap. Retries use the shared OSRequestRetryBackoff and retry trigger from SDK-5481 instead of their own. Co-Authored-By: Claude Opus 5.5 --- .../OneSignal.xcodeproj/project.pbxproj | 24 +- .../Source/OneSignalCommonDefines.h | 8 + .../Source/OSOperationRepo.swift | 11 +- .../Source/OSSessionService.swift | 14 +- .../Source/OSSessionsBackendService.swift | 166 ------- .../Source/OSSessionsRequests.swift | 89 ---- ...erationRepoIdentityVerificationTests.swift | 15 + .../OSSessionsBackendServiceTests.swift | 237 --------- .../OSSessionOperationExecutor.swift | 430 ++++++++++++++++ .../OneSignalUserManagerImpl+Session.swift | 38 ++ .../Source/OneSignalUserManagerImpl.swift | 4 + .../Source/Requests/OSSessionRequests.swift | 290 +++++++++++ .../OSSessionOperationExecutorTests.swift | 464 ++++++++++++++++++ 13 files changed, 1284 insertions(+), 506 deletions(-) delete mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift delete mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift delete mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 126e96b04..f30211396 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -95,13 +95,11 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; 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 */; }; + A5162F01A1B2C3D4E5F60014 /* OSSessionRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.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 */; }; 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 */; }; @@ -160,6 +158,7 @@ 3C64C3322F1066D700693230 /* LiveActivitiesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C64C3312F1066D700693230 /* LiveActivitiesManagerTests.swift */; }; 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */; }; 3C68EFE52D93195E00F0896B /* OSCustomEventsExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */; }; + A5162F01A1B2C3D4E5F60018 /* OSSessionOperationExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60017 /* OSSessionOperationExecutor.swift */; }; 3C68EFE72D931BA600F0896B /* OSRequestCustomEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */; }; 3C7021E32ECF0821001768C6 /* OneSignalFramework.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */; }; 3C7021E42ECF0821001768C6 /* OneSignalFramework.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -219,6 +218,7 @@ 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */; }; 3CB331682F281679000E1801 /* CustomEventsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */; }; 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */; }; + A5162F01A1B2C3D4E5F6001A /* OSSessionOperationExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60019 /* OSSessionOperationExecutorTests.swift */; }; 3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */; }; 3CBB6C262ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */; }; 3CC063942B6D6B6B002BB07F /* OneSignalCore.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063932B6D6B6B002BB07F /* OneSignalCore.m */; }; @@ -1475,6 +1475,7 @@ 3C64C3312F1066D700693230 /* LiveActivitiesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitiesManagerTests.swift; sourceTree = ""; }; 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchUserIntegrationTests.swift; sourceTree = ""; }; 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSCustomEventsExecutor.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F60017 /* OSSessionOperationExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionOperationExecutor.swift; sourceTree = ""; }; 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestCustomEvents.swift; sourceTree = ""; }; 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OneSignalInAppMessagesTests-Bridging-Header.h"; sourceTree = ""; }; 3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAMIntegrationTests.swift; sourceTree = ""; }; @@ -1515,6 +1516,7 @@ 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTests.swift; sourceTree = ""; }; 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventsIntegrationTests.swift; sourceTree = ""; }; 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSCustomEventsExecutorTests.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F60019 /* OSSessionOperationExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionOperationExecutorTests.swift; sourceTree = ""; }; 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMessagingControllerUserStateTests.swift; sourceTree = ""; }; 3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsistencyManagerTestHelpers.swift; sourceTree = ""; }; 3CC063932B6D6B6B002BB07F /* OneSignalCore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalCore.m; sourceTree = ""; }; @@ -1924,13 +1926,11 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; 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 = ""; }; + A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequests.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 = ""; }; 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 = ""; }; @@ -2438,8 +2438,6 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, - A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */, - A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2510,6 +2508,7 @@ children = ( 3C8E6E0028AC0BA10031E48A /* OSIdentityOperationExecutor.swift */, 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */, + A5162F01A1B2C3D4E5F60017 /* OSSessionOperationExecutor.swift */, 3C8E6DFE28AB09AE0031E48A /* OSPropertyOperationExecutor.swift */, 3CE795FA28DBDCE700736BD4 /* OSSubscriptionOperationExecutor.swift */, 3C9AD6BB2B2285FB00BC1540 /* OSUserExecutor.swift */, @@ -2533,6 +2532,7 @@ 3C9AD6C02B22886600BC1540 /* OSRequestUpdateSubscription.swift */, 3C9AD6C42B228A7300BC1540 /* OSRequestDeleteSubscription.swift */, 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */, + A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.swift */, ); path = Requests; sourceTree = ""; @@ -2619,6 +2619,7 @@ C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */, 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, + A5162F01A1B2C3D4E5F60019 /* OSSessionOperationExecutorTests.swift */, FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */, ); @@ -2772,7 +2773,6 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, - A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4694,8 +4694,6 @@ A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */, - A5162F01A1B2C3D4E5F60014 /* OSSessionsRequests.swift in Sources */, - A5162F01A1B2C3D4E5F60012 /* OSSessionsBackendService.swift in Sources */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4799,6 +4797,7 @@ 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, + A5162F01A1B2C3D4E5F6001A /* OSSessionOperationExecutorTests.swift in Sources */, 3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */, E85C1C500D87196C5E20144F /* ExecutorReadYourWriteTests.swift in Sources */, 3C5181A1B2C3D4E5F6A7B802 /* SubscriptionCreateResponseTests.swift in Sources */, @@ -4898,7 +4897,6 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, - A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, @@ -5019,6 +5017,8 @@ 3C0EF49E28A1DBCB00E5434B /* OSUserInternalImpl.swift in Sources */, 3C8E6DFF28AB09AE0031E48A /* OSPropertyOperationExecutor.swift in Sources */, 3C68EFE52D93195E00F0896B /* OSCustomEventsExecutor.swift in Sources */, + A5162F01A1B2C3D4E5F60018 /* OSSessionOperationExecutor.swift in Sources */, + A5162F01A1B2C3D4E5F60014 /* OSSessionRequests.swift in Sources */, 3C9AD6CB2B228B5200BC1540 /* OSRequestIdentifyUser.swift in Sources */, 3C9AD6BC2B2285FB00BC1540 /* OSUserExecutor.swift in Sources */, 3C9AD6C32B22887700BC1540 /* OSRequestCreateUser.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 8a8263e79..22aa524cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -208,6 +208,7 @@ typedef enum {ATTRIBUTED, NOT_ATTRIBUTED} FocusAttributionState; #define PROPERTIES_EXECUTOR_BACKGROUND_TASK @"PROPERTIES_EXECUTOR_BACKGROUND_TASK_" #define SUBSCRIPTION_EXECUTOR_BACKGROUND_TASK @"SUBSCRIPTION_EXECUTOR_BACKGROUND_TASK_" #define CUSTOM_EVENTS_EXECUTOR_BACKGROUND_TASK @"CUSTOM_EVENTS_EXECUTOR_BACKGROUND_TASK_" +#define SESSION_EXECUTOR_BACKGROUND_TASK @"SESSION_EXECUTOR_BACKGROUND_TASK_" // OneSignal constants #define OS_PUSH @"push" @@ -353,6 +354,9 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENT_DELTA @"OS_CUSTOM_EVENT_DELTA" +#define OS_CREATE_SESSION_DELTA @"OS_CREATE_SESSION_DELTA" +#define OS_UPDATE_SESSION_DELTA @"OS_UPDATE_SESSION_DELTA" + // Operation Repo #define OS_OPERATION_REPO_DELTA_QUEUE_KEY @"OS_OPERATION_REPO_DELTA_QUEUE_KEY" @@ -379,6 +383,10 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY" #define OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY" +// Session Executor +#define OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY @"OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY" +#define OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY @"OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY" + // A codeable value larger than this is not written, and a cached queue blob larger than it is dropped without // decoding it. Real queues are a few KB. CFPreferences rejects a value of 4 MB or more on iOS. #define OS_USER_DEFAULTS_MAX_VALUE_BYTES 1048576 diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 810a2eef0..0862bd6dc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -241,11 +241,20 @@ public class OSOperationRepo: NSObject { added and removed, never updated. Should an update path for them appear, this has to narrow to the push type, which the repo cannot see from here: `OSSubscriptionModel` lives in OneSignalUser, so the Delta would have to carry the distinction the way it carries `externalId`. + + Session Deltas are exempt too: the sessions API takes no user JWT, so Identity Verification does + not apply to it. */ private func shouldDropAnonymousDelta(_ delta: OSDelta, ivActive: Bool) -> Bool { - return ivActive && delta.externalId == nil && delta.name != OS_UPDATE_SUBSCRIPTION_DELTA + return ivActive && delta.externalId == nil && !Self.deltasExemptFromIdentityVerification.contains(delta.name) } + private static let deltasExemptFromIdentityVerification: Set = [ + OS_UPDATE_SUBSCRIPTION_DELTA, + OS_CREATE_SESSION_DELTA, + OS_UPDATE_SESSION_DELTA + ] + private func flushDeltaQueue(inBackground: Bool = false) { guard !paused else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo not flushing queue due to being paused") diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 9c6ef0a07..0da8c68af 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -61,7 +61,7 @@ public struct OSSessionRecord: Codable, Equatable { /// Fixed for the whole session so it never mixes paths. Read this rather than the feature /// manager, whose value can change mid-session. public let usesSessionsApi: Bool - let identityModelId: String? + public let identityModelId: String? /// Pinned at session start, so it stays the same after a login or user switch. public internal(set) var onesignalId: String? public internal(set) var subscriptionId: String? @@ -248,6 +248,18 @@ public final class OSSessionService: NSObject { persist() } + /// Set once the sessions API creates the session, if it is still the current one. + public 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 deleted file mode 100644 index e9d35944e..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift +++ /dev/null @@ -1,166 +0,0 @@ -/* - 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 - -struct OSCreateSessionRequestBody: Equatable { - let onesignalId: String - let subscriptionId: String - let startTime: Date - let idempotencyKey: String - let directAttributionId: String? - - init(onesignalId: String, subscriptionId: String, startTime: Date, idempotencyKey: String, directAttributionId: String? = nil) { - self.onesignalId = onesignalId - self.subscriptionId = subscriptionId - self.startTime = startTime - self.idempotencyKey = idempotencyKey - self.directAttributionId = directAttributionId - } -} - -struct OSUpdateSessionRequestBody: Equatable { - let onesignalId: String - let subscriptionId: String - /// Cumulative for the whole session, so a retried or reordered update cannot double count. - let durationSeconds: Int64 - let idempotencyKey: String - /// Set only on the update that ends the session. - let endTime: Date? - - init(onesignalId: String, subscriptionId: String, durationSeconds: Int64, idempotencyKey: String, endTime: Date? = nil) { - self.onesignalId = onesignalId - self.subscriptionId = subscriptionId - self.durationSeconds = durationSeconds - self.idempotencyKey = idempotencyKey - self.endTime = endTime - } -} - -enum OSSessionsApiResult { - case success(Value) - /// No response, 5xx, 408, 429, or a create success without a session ID. - /// Wait at least `retryAfterSeconds` when set. - case retry(statusCode: Int, retryAfterSeconds: Int?) - /// Any other 4xx, or a request the client rejected before sending. It will never succeed as sent. - case drop(statusCode: Int) -} - -extension OSSessionsApiResult: Equatable where Value: Equatable {} - -/// Typed client for the sessions API. -final class OSSessionsBackendService { - static let defaultRetryAfterSeconds = 60 - - private let client: () -> IOneSignalClient - - convenience init() { - self.init(client: { OneSignalCoreImpl.sharedClient() }) - } - - init(client: @escaping () -> IOneSignalClient) { - self.client = client - } - - /// On success the result holds the backend session ID. - func createSession( - appId: String, - body: OSCreateSessionRequestBody, - completion: @escaping (OSSessionsApiResult) -> Void - ) { - let request = OSRequestCreateSession(appId: appId, body: body) - client().execute(request) { response in - guard let sessionId = Self.sessionId(from: response) else { - // Retrying with the same idempotency key returns the session the backend already created. - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionsBackendService: create session response is missing data.session_id") - completion(.retry(statusCode: Self.statusCode(of: response), retryAfterSeconds: nil)) - return - } - completion(.success(sessionId)) - } onFailure: { error in - completion(Self.classifyFailure(error, emptySuccess: .retry(statusCode: error.code, retryAfterSeconds: nil))) - } - } - - /// Reports the session's cumulative duration, and ends it when `body.endTime` is set. - func updateSession( - appId: String, - sessionId: String, - body: OSUpdateSessionRequestBody, - completion: @escaping (OSSessionsApiResult) -> Void - ) { - let request = OSRequestUpdateSession(appId: appId, sessionId: sessionId, body: body) - client().execute(request) { _ in - completion(.success(())) - } onFailure: { error in - completion(Self.classifyFailure(error, emptySuccess: .success(()))) - } - } - - private static func sessionId(from response: [AnyHashable: Any]?) -> String? { - guard let data = response?["data"] as? [String: Any], - let sessionId = data["session_id"] as? String, - !sessionId.isEmpty - else { - return nil - } - return sessionId - } - - private static func statusCode(of response: [AnyHashable: Any]?) -> Int { - (response?["httpStatusCode"] as? NSNumber)?.intValue ?? 202 - } - - /// `emptySuccess` covers a 2xx whose body failed to parse, which `OneSignalClient` reports as a failure. - private static func classifyFailure( - _ error: OneSignalClientError, - emptySuccess: OSSessionsApiResult - ) -> OSSessionsApiResult { - let code = error.code - if (200..<300).contains(code) { - return emptySuccess - } - // 0 means no HTTP response: no network, timeout, or missing privacy consent. The client's - // only negative code is a missing app ID, which a retry cannot fix. - if code == 0 || code == 408 || code == 429 || code >= 500 { - return .retry(statusCode: code, retryAfterSeconds: retryAfterSeconds(error)) - } - return .drop(statusCode: code) - } - - /// Only the delay-seconds form is supported, not the HTTP-date form. - static func retryAfterSeconds(_ error: OneSignalClientError) -> Int? { - let value = error.responseHeaders?.first { key, _ in - (key as? String)?.caseInsensitiveCompare("Retry-After") == .orderedSame - }?.value - if let value { - return Int("\(value)".trimmingCharacters(in: .whitespaces)) ?? defaultRetryAfterSeconds - } - return error.code == 429 ? defaultRetryAfterSeconds : nil - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift deleted file mode 100644 index a861a09e9..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift +++ /dev/null @@ -1,89 +0,0 @@ -/* - 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 - -/// RFC 3339 in UTC with milliseconds, e.g. `2023-11-14T22:13:20.000Z`. -enum OSSessionTimestamp { - private static let formatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - static func string(from date: Date) -> String { - formatter.string(from: date) - } -} - -/// `POST apps/{app_id}/sessions` -final class OSRequestCreateSession: OneSignalRequest { - override var description: String { - "" - } - - init(appId: String, body: OSCreateSessionRequestBody) { - super.init() - var parameters: [String: Any] = [ - "onesignal_id": body.onesignalId, - "subscription_id": body.subscriptionId, - "device_type": DEVICE_TYPE_PUSH, - "start_time": OSSessionTimestamp.string(from: body.startTime), - "idempotency_key": body.idempotencyKey - ] - if let directAttributionId = body.directAttributionId { - parameters["direct_attribution_id"] = directAttributionId - } - self.parameters = parameters - self.method = POST - self.path = "apps/\(appId)/sessions" - } -} - -/// `PATCH apps/{app_id}/sessions/{session_id}` -final class OSRequestUpdateSession: OneSignalRequest { - override var description: String { - "" - } - - init(appId: String, sessionId: String, body: OSUpdateSessionRequestBody) { - super.init() - var parameters: [String: Any] = [ - "onesignal_id": body.onesignalId, - "subscription_id": body.subscriptionId, - "duration_seconds": body.durationSeconds, - "idempotency_key": body.idempotencyKey - ] - if let endTime = body.endTime { - parameters["end_time"] = OSSessionTimestamp.string(from: endTime) - } - self.parameters = parameters - self.method = PATCH - self.path = "apps/\(appId)/sessions/\(sessionId)" - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift index 7b3e405c3..eea4dad37 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift @@ -178,6 +178,21 @@ final class OSOperationRepoIdentityVerificationTests: XCTestCase { XCTAssertEqual(executor.enqueued.map(\.property), ["token"]) } + /// The sessions API takes no user JWT, so session Deltas survive the enqueue drop. + func testAnonymousSessionDeltasAreExemptFromTheEnqueueDrop() { + jwtConfig.hydrate(requiresUserAuth: true) + let repo = makeRepo() + repo.paused = true + + repo.enqueueDelta(OSOperationRepoTestEnvironment.makeDelta(name: OS_CREATE_SESSION_DELTA, externalId: nil, property: "create")) + repo.enqueueDelta(OSOperationRepoTestEnvironment.makeDelta(name: OS_UPDATE_SESSION_DELTA, externalId: nil, property: "update")) + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "identified")) + + OneSignalCoreMocks.waitUntil("identified delta enqueued") { repo.snapshotDeltaQueue().count == 3 } + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["create", "update", "identified"]) + } + /// The rollout flag alone must not suppress; only `jwt_required` turns it on. func testAnonymousDeltasSurviveWhenTheFlagIsOnButTheAppDoesNotRequireAuth() { flagsStore.applyRemoteFlags([FeatureFlag.sdkIdentityVerification.key], metadata: nil) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift deleted file mode 100644 index 8fb76ba04..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift +++ /dev/null @@ -1,237 +0,0 @@ -/* - 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 -@testable import OneSignalOSCore -import XCTest - -private final class FakeClient: NSObject, IOneSignalClient { - enum Outcome { - case success([AnyHashable: Any]?) - case failure(code: Int, headers: [String: String]?) - } - - var outcome: Outcome = .success(nil) - private(set) var requests: [OneSignalRequest] = [] - - func execute( - _ request: OneSignalRequest, - onSuccess successBlock: @escaping OSResultSuccessBlock, - onFailure failureBlock: @escaping OSClientFailureBlock - ) { - requests.append(request) - switch outcome { - case .success(let response): - successBlock(response) - case .failure(let code, let headers): - failureBlock(OneSignalClientError(code: code, message: "failure", responseHeaders: headers, response: nil, underlyingError: nil)) - } - } -} - -final class OSSessionsBackendServiceTests: XCTestCase { - private let createBody = OSCreateSessionRequestBody( - onesignalId: "onesignal-id", - subscriptionId: "subscription-id", - startTime: Date(timeIntervalSince1970: 1_700_000_000), - idempotencyKey: "create-key" - ) - private let updateBody = OSUpdateSessionRequestBody( - onesignalId: "onesignal-id", - subscriptionId: "subscription-id", - durationSeconds: 42, - idempotencyKey: "update-key" - ) - private var client: FakeClient! - private var service: OSSessionsBackendService! - - override func setUp() { - super.setUp() - client = FakeClient() - service = OSSessionsBackendService(client: { [unowned self] in self.client }) - } - - private func create(_ body: OSCreateSessionRequestBody? = nil) -> OSSessionsApiResult? { - var result: OSSessionsApiResult? - service.createSession(appId: "app-id", body: body ?? createBody) { result = $0 } - return result - } - - private func update(_ body: OSUpdateSessionRequestBody? = nil) -> OSSessionsApiResult? { - var result: OSSessionsApiResult? - service.updateSession(appId: "app-id", sessionId: "server-id", body: body ?? updateBody) { result = $0 } - return result - } - - /// `Void` is not `Equatable`, so compare update results through their failure fields. - private func assertUpdate( - _ result: OSSessionsApiResult?, - matches expected: OSSessionsApiResult, - file: StaticString = #filePath, - line: UInt = #line - ) { - let mapped: OSSessionsApiResult? = result.map { - switch $0 { - case .success: return .success("") - case .retry(let statusCode, let retryAfterSeconds): return .retry(statusCode: statusCode, retryAfterSeconds: retryAfterSeconds) - case .drop(let statusCode): return .drop(statusCode: statusCode) - } - } - XCTAssertEqual(mapped, expected, file: file, line: line) - } - - func testCreateSessionPostsRequestBody() { - client.outcome = .success(["data": ["session_id": "server-id"]]) - - _ = create() - - let request = client.requests.first - XCTAssertTrue(request is OSRequestCreateSession) - XCTAssertEqual(request?.method, POST) - XCTAssertEqual(request?.path, "apps/app-id/sessions") - XCTAssertEqual(request?.parameters as NSDictionary?, [ - "onesignal_id": "onesignal-id", - "subscription_id": "subscription-id", - "device_type": 0, - "start_time": "2023-11-14T22:13:20.000Z", - "idempotency_key": "create-key" - ]) - } - - func testCreateSessionIncludesDirectAttributionWhenSet() { - client.outcome = .success(["data": ["session_id": "server-id"]]) - - _ = create(OSCreateSessionRequestBody( - onesignalId: "onesignal-id", - subscriptionId: "subscription-id", - startTime: Date(timeIntervalSince1970: 1_700_000_000), - idempotencyKey: "create-key", - directAttributionId: "notification-id" - )) - - XCTAssertEqual(client.requests.first?.parameters?["direct_attribution_id"] as? String, "notification-id") - } - - func testCreateSessionReturnsSessionIdFrom202() { - client.outcome = .success(["data": ["session_id": "server-id"], "httpStatusCode": 202]) - - XCTAssertEqual(create(), .success("server-id")) - } - - func testCreateSessionRetriesSuccessWithoutSessionId() { - let responses: [[AnyHashable: Any]?] = [ - nil, - ["httpStatusCode": 202], - ["data": [String: Any](), "httpStatusCode": 202], - ["data": ["session_id": ""], "httpStatusCode": 202], - ["data": ["session_id": NSNull()], "httpStatusCode": 202], - ["data": ["session_id": 123], "httpStatusCode": 202], - ["data": "server-id", "httpStatusCode": 202] - ] - for response in responses { - client.outcome = .success(response) - - XCTAssertEqual(create(), .retry(statusCode: 202, retryAfterSeconds: nil), "\(String(describing: response))") - } - } - - func testCreateSessionRetriesUnparsableSuccessBody() { - client.outcome = .failure(code: 202, headers: nil) - - XCTAssertEqual(create(), .retry(statusCode: 202, retryAfterSeconds: nil)) - } - - func testUpdateSessionPatchesRequestBody() { - client.outcome = .success(nil) - - assertUpdate(update(), matches: .success("")) - - let request = client.requests.first - XCTAssertTrue(request is OSRequestUpdateSession) - XCTAssertEqual(request?.method, PATCH) - XCTAssertEqual(request?.path, "apps/app-id/sessions/server-id") - XCTAssertEqual(request?.parameters as NSDictionary?, [ - "onesignal_id": "onesignal-id", - "subscription_id": "subscription-id", - "duration_seconds": 42, - "idempotency_key": "update-key" - ]) - } - - func testUpdateSessionIncludesEndTimeWhenSet() { - _ = update(OSUpdateSessionRequestBody( - onesignalId: "onesignal-id", - subscriptionId: "subscription-id", - durationSeconds: 42, - idempotencyKey: "update-key", - endTime: Date(timeIntervalSince1970: 1_700_000_042.5) - )) - - XCTAssertEqual(client.requests.first?.parameters?["end_time"] as? String, "2023-11-14T22:14:02.500Z") - } - - func testUpdateSessionSucceedsWithUnparsableSuccessBody() { - client.outcome = .failure(code: 202, headers: nil) - - assertUpdate(update(), matches: .success("")) - } - - func testFailuresAreRetriedOnNoResponse5xx408And429() { - for code in [0, 408, 500, 502, 503] { - client.outcome = .failure(code: code, headers: nil) - - XCTAssertEqual(create(), .retry(statusCode: code, retryAfterSeconds: nil), "\(code)") - assertUpdate(update(), matches: .retry(statusCode: code, retryAfterSeconds: nil)) - } - client.outcome = .failure(code: 429, headers: nil) - XCTAssertEqual(create(), .retry(statusCode: 429, retryAfterSeconds: OSSessionsBackendService.defaultRetryAfterSeconds)) - } - - func testOther4xxAndMissingAppIdFailuresAreDropped() { - for code in [-1, 400, 401, 403, 404, 409, 410, 422] { - client.outcome = .failure(code: code, headers: ["Retry-After": "10"]) - - XCTAssertEqual(create(), .drop(statusCode: code), "\(code)") - assertUpdate(update(), matches: .drop(statusCode: code)) - } - } - - func testRetryExposesRetryAfterFromResponse() { - client.outcome = .failure(code: 429, headers: ["Retry-After": "30"]) - XCTAssertEqual(create(), .retry(statusCode: 429, retryAfterSeconds: 30)) - - client.outcome = .failure(code: 503, headers: ["retry-after": "15"]) - assertUpdate(update(), matches: .retry(statusCode: 503, retryAfterSeconds: 15)) - } - - func testUnparsableRetryAfterFallsBackToDefault() { - client.outcome = .failure(code: 503, headers: ["Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"]) - - XCTAssertEqual(create(), .retry(statusCode: 503, retryAfterSeconds: OSSessionsBackendService.defaultRetryAfterSeconds)) - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift new file mode 100644 index 000000000..6d28d4420 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -0,0 +1,430 @@ +/* + 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 OneSignalOSCore + +/// Keys of the value an `OS_CREATE_SESSION_DELTA` or `OS_UPDATE_SESSION_DELTA` carries. The Delta's +/// `property` is the local session ID. +enum OSSessionDeltaKey { + static let appId = "app_id" + static let startTime = "start_time" + static let directAttributionId = "direct_attribution_id" + static let activeDuration = "active_duration" + static let endTime = "end_time" + static let onesignalId = "onesignal_id" + static let subscriptionId = "subscription_id" + static let serverSessionId = "server_session_id" +} + +/** + Sends sessions API Requests: a session's create, then its updates once the create returns the server + session ID. Only the first queued Request of a session is sent, so they reach the backend in order. + */ +class OSSessionOperationExecutor: OSOperationExecutor { + static let maxQueuedRequests = 100 + static let maxFailedAttempts = 5 + + var supportedDeltas: [String] = [OS_CREATE_SESSION_DELTA, OS_UPDATE_SESSION_DELTA] + private var deltaQueue: [OSDelta] = [] + private var requestQueue: [OSSessionRequest] = [] + /// By local session ID, for an update built after its create already succeeded. + private var serverSessionIds: [String: String] = [:] + private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing + private let retryBackoff: OSRequestRetryBackoff + private let nowProvider: () -> Date + + // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. + private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSessionOperationExecutor", target: .global()) + + init( + newRecordsState: OSNewRecordsState, + auth: OSRequestAuthorizing, + retryBackoff: OSRequestRetryBackoff = OSRequestRetryBackoff(), + nowProvider: @escaping () -> Date = { Date() } + ) { + self.newRecordsState = newRecordsState + self.auth = auth + self.retryBackoff = retryBackoff + self.nowProvider = nowProvider + uncacheDeltas() + uncacheRequests() + } + + private func uncacheDeltas() { + guard var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor error encountered reading from cache for \(OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY)") + return + } + deltaQueue.removeAll { delta in + guard OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil else { + return false + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor.init dropped: \(delta)") + return true + } + self.deltaQueue = deltaQueue + cacheDeltas() + } + + private func uncacheRequests() { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [Any] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor error encountered reading from cache for \(OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY)") + return + } + // Hook each uncached Request to the model in the store + requestQueue = cached.compactMap { $0 as? OSSessionRequest }.filter { request in + if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { + request.identityModel = identityModel + return true + } + if request.identityModel.onesignalId != nil { + // Its user is no longer loaded but was created, so the Request can still be sent + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + return true + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor.init dropped: \(request)") + return false + } + removeOrphanedUpdates() + for case let update as OSRequestUpdateSession in requestQueue { + serverSessionIds[update.localSessionId] = update.serverSessionId ?? serverSessionIds[update.localSessionId] + } + cacheRequests() + } + + func enqueueDelta(_ delta: OSDelta) { + self.dispatchQueue.async { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSSessionOperationExecutor enqueue delta \(delta)") + self.deltaQueue.append(delta) + } + } + + func cacheDeltaQueue() { + self.dispatchQueue.async { + self.cacheDeltas() + } + } + + /** + The sessions API takes no user JWT, so Identity Verification does not apply to session Requests. + But an anonymous user is never created while it is required, so a create still waiting for that + user's `onesignal_id` can never be sent. Drop those, with their updates. + */ + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let countBefore = self.requestQueue.count + self.requestQueue.removeAll { request in + request is OSRequestCreateSession + && request.identityModel.externalId == nil + && request.identityModel.onesignalId == nil + } + guard self.requestQueue.count != countBefore else { + return + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(countBefore - self.requestQueue.count) creates for anonymous users that will never be created") + self.removeOrphanedUpdates() + self.cacheRequests() + } + } + + func processDeltaQueue(inBackground: Bool) { + self.dispatchQueue.async { + if !self.deltaQueue.isEmpty { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSSessionOperationExecutor processDeltaQueue with queue: \(self.deltaQueue)") + for delta in self.deltaQueue { + if let request = self.makeRequest(from: delta) { + self.admit(request) + } + } + self.deltaQueue.removeAll() + self.cacheRequests() + self.cacheDeltas() + } + self.processRequestQueue(inBackground: inBackground) + } + } + + /// The Requests waiting to be sent, for tests. + var queuedRequests: [OSSessionRequest] { + dispatchQueue.sync { requestQueue } + } + + private func cacheDeltas() { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY, withValue: deltaQueue) + } + + private func cacheRequests() { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY, withValue: requestQueue) + } +} + +// MARK: - Queueing + +private extension OSSessionOperationExecutor { + func makeRequest(from delta: OSDelta) -> OSSessionRequest? { + guard let value = delta.value as? [String: Any], + let appId = value[OSSessionDeltaKey.appId] as? String, + let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) + else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor.processDeltaQueue dropped: \(delta)") + return nil + } + let localSessionId = delta.property + let onesignalId = value[OSSessionDeltaKey.onesignalId] as? String + let subscriptionId = value[OSSessionDeltaKey.subscriptionId] as? String + if delta.name == OS_CREATE_SESSION_DELTA { + return OSRequestCreateSession( + appId: appId, + localSessionId: localSessionId, + startTime: Date(timeIntervalSince1970: value[OSSessionDeltaKey.startTime] as? TimeInterval ?? 0), + directAttributionId: value[OSSessionDeltaKey.directAttributionId] as? String, + identityModel: identityModel, + ownerExternalId: delta.externalId, + onesignalId: onesignalId, + subscriptionId: subscriptionId + ) + } + return OSRequestUpdateSession( + appId: appId, + localSessionId: localSessionId, + serverSessionId: value[OSSessionDeltaKey.serverSessionId] as? String ?? serverSessionIds[localSessionId], + activeDuration: value[OSSessionDeltaKey.activeDuration] as? TimeInterval ?? 0, + endTime: (value[OSSessionDeltaKey.endTime] as? TimeInterval).map { Date(timeIntervalSince1970: $0) }, + identityModel: identityModel, + ownerExternalId: delta.externalId, + onesignalId: onesignalId, + subscriptionId: subscriptionId + ) + } + + func admit(_ request: OSSessionRequest) { + if let update = request as? OSRequestUpdateSession, !admitUpdate(update) { + return + } + requestQueue.append(request) + while requestQueue.count > Self.maxQueuedRequests { + let oldest = requestQueue[0] + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor more than \(Self.maxQueuedRequests) Requests queued, dropping oldest: \(oldest)") + drop(oldest) + } + } + + /** + Folds an update into its session's queued ones, which are all cumulative. Returns false when the + update should not be queued: its session already ended, it can never get a server session ID, or + a queued update already has the same body. + */ + func admitUpdate(_ update: OSRequestUpdateSession) -> Bool { + let sessionId = update.localSessionId + let queuedUpdates = requestQueue.compactMap { $0 as? OSRequestUpdateSession }.filter { $0.localSessionId == sessionId } + guard !queuedUpdates.contains(where: \.isEnd) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), the session already ended") + return false + } + update.serverSessionId = update.serverSessionId ?? queuedUpdates.lazy.compactMap(\.serverSessionId).first + guard update.serverSessionId != nil || hasCreate(forSession: sessionId) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), its session has no create or server session ID") + return false + } + // An update in flight still counts, so a later one cannot report less time. + update.activeDuration = queuedUpdates.map(\.activeDuration).reduce(update.activeDuration, max) + + let pending = queuedUpdates.filter { !$0.sentToClient } + update.failedAttempts = pending.map(\.failedAttempts).reduce(update.failedAttempts, max) + update.onesignalId = update.onesignalId ?? pending.lazy.compactMap(\.onesignalId).first + update.subscriptionId = update.subscriptionId ?? pending.lazy.compactMap(\.subscriptionId).first + if pending.count == 1, let queued = pending.first, + queued.activeDuration == update.activeDuration, queued.endTime == update.endTime { + // Keep the queued update and its idempotency key, since the body did not change. + queued.onesignalId = queued.onesignalId ?? update.onesignalId + queued.subscriptionId = queued.subscriptionId ?? update.subscriptionId + return false + } + requestQueue.removeAll { request in pending.contains { $0 === request } } + // Carry a backoff over, or the replacement would skip it. + pending.last.map { retryBackoff.transferWait(from: $0, to: update) } + return true + } + + func hasCreate(forSession sessionId: String) -> Bool { + requestQueue.contains { $0 is OSRequestCreateSession && $0.localSessionId == sessionId } + } + + /// Removes a Request that will not be sent. A dropped create takes its session's updates with it, + /// since they could never get a server session ID. + func drop(_ request: OSSessionRequest) { + requestQueue.removeAll { $0 === request } + removeOrphanedUpdates() + } + + func removeOrphanedUpdates() { + let sessionsWithCreate = Set(requestQueue.filter { $0 is OSRequestCreateSession }.map(\.localSessionId)) + requestQueue.removeAll { request in + guard let update = request as? OSRequestUpdateSession, + update.serverSessionId == nil, + !sessionsWithCreate.contains(update.localSessionId) + else { + return false + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(update), its session has no create") + return true + } + } +} + +// MARK: - Sending + +private extension OSSessionOperationExecutor { + /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. + func processRequestQueue(inBackground: Bool) { + if removeUnsendableRequests() { + cacheRequests() + } + var sessionsSeen = Set() + for request in requestQueue { + guard sessionsSeen.insert(request.localSessionId).inserted, + !request.sentToClient, + !retryBackoff.isWaiting(request), + request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) + else { + continue + } + executeRequest(request, inBackground: inBackground) + } + } + + /// Drops Requests recorded for another app, and those past their age limit (see `OSRequestAging`). + func removeUnsendableRequests() -> Bool { + let countBefore = requestQueue.count + let currentAppId = OneSignalIdentifiers.currentAppId + let now = nowProvider() + let currentExternalId = OSRequestAging.currentExternalId + for request in requestQueue { + if let currentAppId, request.appId != currentAppId { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(request), it belongs to another app") + drop(request) + } else if let age = OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: nil, now: now, currentExternalId: currentExternalId) { + OSRequestAging.logDrop(of: "\(type(of: request))", owner: request.ownerExternalId, age: age, executor: "OSSessionOperationExecutor") + drop(request) + } + } + return requestQueue.count != countBefore + } + + func executeRequest(_ request: OSSessionRequest, inBackground: Bool) { + request.sentToClient = true + + let backgroundTaskIdentifier = SESSION_EXECUTOR_BACKGROUND_TASK + UUID().uuidString + if inBackground { + OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) + } + + OneSignalCoreImpl.sharedClient().execute(request) { response in + self.dispatchQueue.async { + self.handleSuccess(request, response: response) + if inBackground { + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + } onFailure: { error in + self.dispatchQueue.async { + self.handleFailure(request, error: error) + if inBackground { + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + } + } + + func handleSuccess(_ request: OSSessionRequest, response: [AnyHashable: Any]?) { + guard let create = request as? OSRequestCreateSession else { + requestQueue.removeAll { $0 === request } + cacheRequests() + return + } + guard let data = response?["data"] as? [String: Any], + let serverSessionId = data["session_id"] as? String, + !serverSessionId.isEmpty + else { + // Retrying with the same idempotency key returns the session the backend already created. + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor create session response is missing data.session_id") + let statusCode = (response?["httpStatusCode"] as? NSNumber)?.intValue ?? 202 + retry(request, error: OneSignalClientError(code: statusCode, message: "Missing data.session_id", responseHeaders: nil, response: response, underlyingError: nil)) + return + } + requestQueue.removeAll { $0 === request } + serverSessionIds[create.localSessionId] = serverSessionId + for case let update as OSRequestUpdateSession in requestQueue where update.localSessionId == create.localSessionId { + update.serverSessionId = update.serverSessionId ?? serverSessionId + } + // Updates wait out the post-create delay, so the backend has the session first. + newRecordsState.add(serverSessionId) + OSSessionService.shared.setServerSessionId(serverSessionId, forSessionId: create.localSessionId) + cacheRequests() + } + + func handleFailure(_ request: OSSessionRequest, error: OneSignalClientError) { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor \(request) failed with status \(error.code)") + let code = error.code + if (200..<300).contains(code) { + // `OneSignalClient` reports a 2xx whose body failed to parse as a failure. + if request is OSRequestCreateSession { + retry(request, error: error) + } else { + requestQueue.removeAll { $0 === request } + cacheRequests() + } + } else if code == 0 || code == 408 || code == 429 || code >= 500 { + // 0 means no HTTP response: no network, timeout, or missing privacy consent. + retry(request, error: error) + } else { + // Any other 4xx, or a missing app ID, which the client reports as a negative code. + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(request) after a \(code)") + drop(request) + cacheRequests() + } + } + + /// Backs off through the shared `OSRequestRetryBackoff`. Only attempts the backend answered count + /// toward the limit, so a Request made offline keeps retrying until the network returns. + func retry(_ request: OSSessionRequest, error: OneSignalClientError) { + request.sentToClient = false + if error.code != 0 { + request.failedAttempts += 1 + } + guard request.failedAttempts < Self.maxFailedAttempts else { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(request) after too many failures") + drop(request) + cacheRequests() + return + } + retryBackoff.recordFailure(request, error: error) + cacheRequests() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index fca1f94d0..409d061c6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -41,4 +41,42 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { public func sessionOnesignalId(identityModelId: String) -> String? { identityModelRepo.get(modelId: identityModelId)?.onesignalId } + + /// Queues the session's create. Does nothing unless the session uses the sessions API. + func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String? = nil) { + var value: [String: Any] = [OSSessionDeltaKey.startTime: record.startTime] + value[OSSessionDeltaKey.directAttributionId] = directAttributionId + enqueueSessionDelta(OS_CREATE_SESSION_DELTA, record: record, value: value) + } + + /// Queues the session's cumulative foreground time, ending the session when `endTime` is set. + func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: Date? = nil) { + var value: [String: Any] = [OSSessionDeltaKey.activeDuration: record.activeDuration] + value[OSSessionDeltaKey.endTime] = endTime?.timeIntervalSince1970 + value[OSSessionDeltaKey.serverSessionId] = record.serverSessionId + enqueueSessionDelta(OS_UPDATE_SESSION_DELTA, record: record, value: value) + } + + private func enqueueSessionDelta(_ name: String, record: OSSessionRecord, value: [String: Any]) { + guard record.usesSessionsApi, + let identityModelId = record.identityModelId, + let identityModel = identityModelRepo.get(modelId: identityModelId), + let appId = OneSignalIdentifiers.currentAppId + else { + return + } + var value = value + value[OSSessionDeltaKey.appId] = appId + value[OSSessionDeltaKey.onesignalId] = record.onesignalId + value[OSSessionDeltaKey.subscriptionId] = record.subscriptionId + let delta = OSDelta( + name: name, + identityModelId: identityModelId, + externalId: identityModel.externalId, + model: identityModel, + property: record.sessionId, + value: value + ) + operationRepo.enqueueDelta(delta) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 244b6b39f..4f5f91493 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -219,6 +219,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var identityExecutor: OSIdentityOperationExecutor? var subscriptionExecutor: OSSubscriptionOperationExecutor? var customEventsExecutor: OSCustomEventsExecutor? + var sessionExecutor: OSSessionOperationExecutor? private override init() { let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) @@ -334,14 +335,17 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) + let sessionExecutor = OSSessionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor self.subscriptionExecutor = subscriptionExecutor self.customEventsExecutor = customEventsExecutor + self.sessionExecutor = sessionExecutor operationRepo.addExecutor(identityExecutor) operationRepo.addExecutor(propertyExecutor) operationRepo.addExecutor(subscriptionExecutor) operationRepo.addExecutor(customEventsExecutor) + operationRepo.addExecutor(sessionExecutor) // After the executors: a cached requirement makes `start()` flush right away, and the // Deltas restored at launch can only route once the map above is populated. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift new file mode 100644 index 000000000..b9c035faa --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -0,0 +1,290 @@ +/* + 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 OneSignalOSCore + +/// RFC 3339 in UTC with milliseconds, e.g. `2023-11-14T22:13:20.000Z`. +enum OSSessionTimestamp { + private static let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} + +/// A sessions API Request, sent and retried by `OSSessionOperationExecutor`. +protocol OSSessionRequest: OSUserRequest { + /// The app the session was recorded for. A Request for any other app is dropped. + var appId: String { get } + /// `OSSessionRecord.sessionId`, which stands in for the backend session ID until the create succeeds. + var localSessionId: String { get } + var identityModel: OSIdentityModel { get set } + /// Failed attempts the backend answered, persisted so the limit holds across restarts. + var failedAttempts: Int { get set } +} + +/// The session's pinned user, falling back to the identity model once the backend creates it. +/// The push subscription is the same device subscription across logins, so it falls back to the +/// current one. +private func resolvedIds(onesignalId: String?, subscriptionId: String?, identityModel: OSIdentityModel) -> (String, String)? { + guard let onesignalId = onesignalId ?? identityModel.onesignalId, + let subscriptionId = subscriptionId ?? OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId + else { + return nil + } + return (onesignalId, subscriptionId) +} + +/// `POST apps/{app_id}/sessions` +final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { + var sentToClient = false + let appId: String + let localSessionId: String + let startTime: Date + let directAttributionId: String? + var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + private(set) var onesignalId: String? + private(set) var subscriptionId: String? + var failedAttempts = 0 + + /// The sessions API takes no user JWT. + var sendsUnsigned: Bool { true } + + override var description: String { + "" + } + + init( + appId: String, + localSessionId: String, + startTime: Date, + directAttributionId: String?, + identityModel: OSIdentityModel, + ownerExternalId: String?, + onesignalId: String?, + subscriptionId: String? + ) { + self.appId = appId + self.localSessionId = localSessionId + self.startTime = startTime + self.directAttributionId = directAttributionId + self.identityModel = identityModel + self.ownerExternalId = ownerExternalId + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + super.init() + self.method = POST + } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + guard let (onesignalId, subscriptionId) = resolvedIds(onesignalId: onesignalId, subscriptionId: subscriptionId, identityModel: identityModel), + newRecordsState.canAccess(onesignalId), + newRecordsState.canAccess(subscriptionId), + auth.authorize(self) + else { + return false + } + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + var parameters: [String: Any] = [ + "onesignal_id": onesignalId, + "subscription_id": subscriptionId, + "device_type": DEVICE_TYPE_PUSH, + "start_time": OSSessionTimestamp.string(from: startTime), + // The local session ID, so a create replayed from any process returns the same session. + "idempotency_key": localSessionId + ] + parameters["direct_attribution_id"] = directAttributionId + self.parameters = parameters + self.path = "apps/\(appId)/sessions" + return true + } + + func encode(with coder: NSCoder) { + coder.encode(appId, forKey: "appId") + coder.encode(localSessionId, forKey: "localSessionId") + coder.encode(startTime, forKey: "startTime") + coder.encode(directAttributionId, forKey: "directAttributionId") + coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(onesignalId, forKey: "onesignalId") + coder.encode(subscriptionId, forKey: "subscriptionId") + coder.encode(failedAttempts, forKey: "failedAttempts") + coder.encode(timestamp, forKey: "timestamp") + } + + required init?(coder: NSCoder) { + guard let appId = coder.decodeObject(forKey: "appId") as? String, + let localSessionId = coder.decodeObject(forKey: "localSessionId") as? String, + let startTime = coder.decodeObject(forKey: "startTime") as? Date, + let identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel, + let timestamp = coder.decodeObject(forKey: "timestamp") as? Date + else { + return nil + } + self.appId = appId + self.localSessionId = localSessionId + self.startTime = startTime + self.directAttributionId = coder.decodeObject(forKey: "directAttributionId") as? String + self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String + self.onesignalId = coder.decodeObject(forKey: "onesignalId") as? String + self.subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String + self.failedAttempts = coder.decodeInteger(forKey: "failedAttempts") + super.init() + self.method = POST + self.timestamp = timestamp + } +} + +/// `PATCH apps/{app_id}/sessions/{session_id}` +final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { + var sentToClient = false + let appId: String + let localSessionId: String + /// Set once the session's create succeeds. + var serverSessionId: String? + /// Cumulative for the whole session, so a retried or reordered update cannot double count. + var activeDuration: TimeInterval + /// Set only on the update that ends the session. + let endTime: Date? + var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + var onesignalId: String? + var subscriptionId: String? + /// Generated once and persisted, so every retry, including after a restart, is deduplicated by the backend. + let idempotencyKey: String + var failedAttempts = 0 + + /// The sessions API takes no user JWT. + var sendsUnsigned: Bool { true } + + var isEnd: Bool { endTime != nil } + + override var description: String { + "" + } + + init( + appId: String, + localSessionId: String, + serverSessionId: String?, + activeDuration: TimeInterval, + endTime: Date?, + identityModel: OSIdentityModel, + ownerExternalId: String?, + onesignalId: String?, + subscriptionId: String? + ) { + self.appId = appId + self.localSessionId = localSessionId + self.serverSessionId = serverSessionId + self.activeDuration = activeDuration + self.endTime = endTime + self.identityModel = identityModel + self.ownerExternalId = ownerExternalId + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + self.idempotencyKey = UUID().uuidString + super.init() + self.method = PATCH + } + + /// Waits out the post-create delay on the server session ID, so the backend has the session first. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + guard let serverSessionId, + let sessionIdSegment = OSUrlPath.segment(serverSessionId), + newRecordsState.canAccess(serverSessionId), + let (onesignalId, subscriptionId) = resolvedIds(onesignalId: onesignalId, subscriptionId: subscriptionId, identityModel: identityModel), + auth.authorize(self) + else { + return false + } + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + var parameters: [String: Any] = [ + "onesignal_id": onesignalId, + "subscription_id": subscriptionId, + "duration_seconds": Int64(activeDuration), + "idempotency_key": idempotencyKey + ] + if let endTime { + parameters["end_time"] = OSSessionTimestamp.string(from: endTime) + } + self.parameters = parameters + self.path = "apps/\(appId)/sessions/\(sessionIdSegment)" + return true + } + + func encode(with coder: NSCoder) { + coder.encode(appId, forKey: "appId") + coder.encode(localSessionId, forKey: "localSessionId") + coder.encode(serverSessionId, forKey: "serverSessionId") + coder.encode(activeDuration, forKey: "activeDuration") + coder.encode(endTime, forKey: "endTime") + coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(onesignalId, forKey: "onesignalId") + coder.encode(subscriptionId, forKey: "subscriptionId") + coder.encode(idempotencyKey, forKey: "idempotencyKey") + coder.encode(failedAttempts, forKey: "failedAttempts") + coder.encode(timestamp, forKey: "timestamp") + } + + required init?(coder: NSCoder) { + guard let appId = coder.decodeObject(forKey: "appId") as? String, + let localSessionId = coder.decodeObject(forKey: "localSessionId") as? String, + let identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel, + let idempotencyKey = coder.decodeObject(forKey: "idempotencyKey") as? String, + let timestamp = coder.decodeObject(forKey: "timestamp") as? Date + else { + return nil + } + self.appId = appId + self.localSessionId = localSessionId + self.serverSessionId = coder.decodeObject(forKey: "serverSessionId") as? String + self.activeDuration = coder.decodeDouble(forKey: "activeDuration") + self.endTime = coder.decodeObject(forKey: "endTime") as? Date + self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String + self.onesignalId = coder.decodeObject(forKey: "onesignalId") as? String + self.subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String + self.idempotencyKey = idempotencyKey + self.failedAttempts = coder.decodeInteger(forKey: "failedAttempts") + super.init() + self.method = PATCH + self.timestamp = timestamp + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift new file mode 100644 index 000000000..c0971308d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -0,0 +1,464 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Holds every Request until the test answers it. +private final class FakeSessionsClient: NSObject, IOneSignalClient { + struct Call { + let request: OneSignalRequest + let onSuccess: OSResultSuccessBlock + let onFailure: OSClientFailureBlock + } + + private let lock = NSLock() + private var _calls: [Call] = [] + + var calls: [Call] { lock.withLock { _calls } } + var requests: [OneSignalRequest] { calls.map(\.request) } + + func execute(_ request: OneSignalRequest, onSuccess: @escaping OSResultSuccessBlock, onFailure: @escaping OSClientFailureBlock) { + lock.withLock { _calls.append(Call(request: request, onSuccess: onSuccess, onFailure: onFailure)) } + } + + func succeedLast(_ response: [AnyHashable: Any]? = nil) { + calls.last?.onSuccess(response) + } + + func failLast(code: Int, headers: [String: String]? = nil) { + calls.last?.onFailure(OneSignalClientError(code: code, message: "failure", responseHeaders: headers, response: nil, underlyingError: nil)) + } +} + +final class OSSessionOperationExecutorTests: XCTestCase { + private let externalId = "session-external-id" + private let onesignalId = "session-onesignal-id" + private var client: FakeSessionsClient! + private var newRecordsState: MockNewRecordsState! + private var user: OSUserInternal! + private var uptime: TimeInterval = 1000 + private var retryBackoff: OSRequestRetryBackoff! + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSSessionService.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + client = FakeSessionsClient() + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + user = OneSignalUserMocks.setUserManagerInternalUser(externalId: externalId, onesignalId: onesignalId) + uptime = 1000 + retryBackoff = OSRequestRetryBackoff(uptime: { [unowned self] in self.uptime }, jitter: { 1 }) + } + + override func tearDownWithError() throws { + OSSessionService.reset() + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeExecutor() -> OSSessionOperationExecutor { + OSSessionOperationExecutor( + newRecordsState: newRecordsState, + auth: OneSignalUserManagerImpl.sharedInstance.requestAuth, + retryBackoff: retryBackoff + ) + } + + private func createDelta(session: String = "session-1", directAttributionId: String? = nil, identityModel: OSIdentityModel? = nil) -> OSDelta { + var value: [String: Any] = [ + OSSessionDeltaKey.appId: "test-app-id", + OSSessionDeltaKey.startTime: 1_700_000_000.0 + ] + value[OSSessionDeltaKey.directAttributionId] = directAttributionId + return delta(OS_CREATE_SESSION_DELTA, session: session, value: value, identityModel: identityModel) + } + + private func updateDelta(session: String = "session-1", duration: TimeInterval, endTime: TimeInterval? = nil, serverSessionId: String? = nil) -> OSDelta { + var value: [String: Any] = [ + OSSessionDeltaKey.appId: "test-app-id", + OSSessionDeltaKey.activeDuration: duration + ] + value[OSSessionDeltaKey.endTime] = endTime + value[OSSessionDeltaKey.serverSessionId] = serverSessionId + return delta(OS_UPDATE_SESSION_DELTA, session: session, value: value, identityModel: nil) + } + + private func delta(_ name: String, session: String, value: [String: Any], identityModel: OSIdentityModel?) -> OSDelta { + let identityModel = identityModel ?? user.identityModel + return OSDelta(name: name, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: identityModel, property: session, value: value) + } + + /// Enqueues the Deltas, runs one flush, and waits for it. + @discardableResult + private func flush(_ executor: OSSessionOperationExecutor, _ deltas: OSDelta...) -> [OSSessionRequest] { + deltas.forEach(executor.enqueueDelta) + executor.processDeltaQueue(inBackground: false) + return executor.queuedRequests + } + + private func createSucceeds(_ executor: OSSessionOperationExecutor, serverSessionId: String = "server-1") { + client.succeedLast(["data": ["session_id": serverSessionId]]) + _ = executor.queuedRequests + } + + private func fail(_ executor: OSSessionOperationExecutor, code: Int, headers: [String: String]? = nil) { + client.failLast(code: code, headers: headers) + _ = executor.queuedRequests + } + + // MARK: - Requests + + func testCreateSendsSessionBodyKeyedByLocalSessionId() throws { + let executor = makeExecutor() + + flush(executor, createDelta(directAttributionId: "notification-id")) + + let request = try XCTUnwrap(client.requests.last as? OSRequestCreateSession) + XCTAssertEqual(request.path, "apps/test-app-id/sessions") + XCTAssertEqual(request.method, POST) + XCTAssertEqual(request.parameters?["onesignal_id"] as? String, onesignalId) + XCTAssertEqual(request.parameters?["subscription_id"] as? String, testPushSubId) + XCTAssertEqual(request.parameters?["start_time"] as? String, "2023-11-14T22:13:20.000Z") + XCTAssertEqual(request.parameters?["idempotency_key"] as? String, "session-1") + XCTAssertEqual(request.parameters?["direct_attribution_id"] as? String, "notification-id") + } + + func testCreateWaitsForItsUserToBeCreated() { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let executor = makeExecutor() + + flush(executor, createDelta()) + XCTAssertTrue(client.requests.isEmpty) + + user.identityModel.addAliases([OS_ONESIGNAL_ID: onesignalId]) + flush(executor) + XCTAssertEqual((client.requests.last as? OSRequestCreateSession)?.parameters?["onesignal_id"] as? String, onesignalId) + } + + func testUpdateWaitsForTheCreateThenUsesItsServerSessionId() throws { + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 42, endTime: 1_700_000_100)) + XCTAssertEqual(client.requests.count, 1) + + createSucceeds(executor) + flush(executor) + + let update = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(update.path, "apps/test-app-id/sessions/server-1") + XCTAssertEqual(update.method, PATCH) + XCTAssertEqual(update.parameters?["duration_seconds"] as? Int64, 42) + XCTAssertEqual(update.parameters?["end_time"] as? String, "2023-11-14T22:15:00.000Z") + XCTAssertEqual(update.parameters?["idempotency_key"] as? String, update.idempotencyKey) + } + + func testUpdateWaitsOutThePostCreateDelay() { + newRecordsState.holdWhilePresent = true + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 10)) + + createSucceeds(executor) + flush(executor) + + XCTAssertEqual(client.requests.count, 1) + XCTAssertFalse(newRecordsState.canAccess("server-1")) + } + + func testUpdateBuiltAfterItsCreateSucceededGetsTheServerSessionId() { + let executor = makeExecutor() + flush(executor, createDelta()) + createSucceeds(executor) + + flush(executor, updateDelta(duration: 10)) + + XCTAssertEqual((client.requests.last as? OSRequestUpdateSession)?.serverSessionId, "server-1") + } + + // MARK: - Coalescing + + func testUpdatesCoalesceToTheHighestDuration() { + let executor = makeExecutor() + + let queued = flush(executor, createDelta(), updateDelta(duration: 10), updateDelta(duration: 5)) + + let updates = queued.compactMap { $0 as? OSRequestUpdateSession } + XCTAssertEqual(updates.map(\.activeDuration), [10]) + } + + func testEndReplacesQueuedUpdatesAndLaterUpdatesAreDiscarded() { + let executor = makeExecutor() + + let queued = flush(executor, createDelta(), updateDelta(duration: 10), updateDelta(duration: 20, endTime: 1_700_000_100), updateDelta(duration: 30)) + + let updates = queued.compactMap { $0 as? OSRequestUpdateSession } + XCTAssertEqual(updates.count, 1) + XCTAssertEqual(updates.first?.activeDuration, 20) + XCTAssertTrue(updates.first?.isEnd == true) + } + + func testUnchangedUpdateKeepsTheQueuedIdempotencyKey() { + let executor = makeExecutor() + let first = flush(executor, createDelta(), updateDelta(duration: 10)).compactMap { $0 as? OSRequestUpdateSession } + + let second = flush(executor, updateDelta(duration: 10)).compactMap { $0 as? OSRequestUpdateSession } + + XCTAssertEqual(second.map(\.idempotencyKey), first.map(\.idempotencyKey)) + } + + func testUpdateInFlightCountsTowardTheHighestDuration() { + let executor = makeExecutor() + flush(executor, updateDelta(duration: 30, serverSessionId: "server-1")) + XCTAssertEqual(client.requests.count, 1) + + let queued = flush(executor, updateDelta(duration: 20)) + + XCTAssertEqual(queued.compactMap { $0 as? OSRequestUpdateSession }.map(\.activeDuration), [30, 30]) + } + + func testUpdateWithoutACreateOrServerSessionIdIsDiscarded() { + let executor = makeExecutor() + + XCTAssertTrue(flush(executor, updateDelta(duration: 10)).isEmpty) + } + + func testCapDropsTheOldestCreateWithItsUpdates() { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let executor = makeExecutor() + flush(executor, createDelta(session: "oldest"), updateDelta(session: "oldest", duration: 10)) + + let deltas = (0.. Date: Wed, 14 Oct 2026 17:10:48 -0700 Subject: [PATCH 02/10] fix(sessions): pin update IDs to create, gate on consent --- .../Source/OSSessionService.swift | 7 +- .../OSSessionOperationExecutor.swift | 119 ++++++++---------- .../Source/Requests/OSSessionRequests.swift | 94 +++++++------- .../OSSessionOperationExecutorTests.swift | 119 +++++++++++------- .../UserJwtLifecycleTests.swift | 5 +- 5 files changed, 186 insertions(+), 158 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 0da8c68af..99eaf7143 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -248,14 +248,17 @@ public final class OSSessionService: NSObject { persist() } - /// Set once the sessions API creates the session, if it is still the current one. - public func setServerSessionId(_ serverSessionId: String, forSessionId sessionId: String) { + /// Set once the sessions API creates the session, if it is still the current one. Records the IDs + /// the create sent, which the session's updates must repeat. + public func setCreatedSession(forSessionId sessionId: String, serverSessionId: String, onesignalId: String, subscriptionId: String) { stateLock.withLock { loadIfNeeded() guard record?.sessionId == sessionId, record?.serverSessionId == nil else { return } record?.serverSessionId = serverSessionId + record?.onesignalId = onesignalId + record?.subscriptionId = subscriptionId persist() } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index 6d28d4420..2a0c522f8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -28,32 +28,21 @@ import Foundation import OneSignalCore import OneSignalOSCore -/// Keys of the value an `OS_CREATE_SESSION_DELTA` or `OS_UPDATE_SESSION_DELTA` carries. The Delta's -/// `property` is the local session ID. -enum OSSessionDeltaKey { - static let appId = "app_id" - static let startTime = "start_time" - static let directAttributionId = "direct_attribution_id" - static let activeDuration = "active_duration" - static let endTime = "end_time" - static let onesignalId = "onesignal_id" - static let subscriptionId = "subscription_id" - static let serverSessionId = "server_session_id" -} - /** Sends sessions API Requests: a session's create, then its updates once the create returns the server session ID. Only the first queued Request of a session is sent, so they reach the backend in order. + Failures are retried through the shared `OSRequestRetryBackoff`. */ class OSSessionOperationExecutor: OSOperationExecutor { static let maxQueuedRequests = 100 - static let maxFailedAttempts = 5 + /// A create answered without a `session_id` is retried this many times, then dropped. + static let maxMissingSessionIdAttempts = 3 var supportedDeltas: [String] = [OS_CREATE_SESSION_DELTA, OS_UPDATE_SESSION_DELTA] private var deltaQueue: [OSDelta] = [] private var requestQueue: [OSSessionRequest] = [] /// By local session ID, for an update built after its create already succeeded. - private var serverSessionIds: [String: String] = [:] + private var createdSessions: [String: OSCreatedSession] = [:] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing private let retryBackoff: OSRequestRetryBackoff @@ -113,7 +102,9 @@ class OSSessionOperationExecutor: OSOperationExecutor { } removeOrphanedUpdates() for case let update as OSRequestUpdateSession in requestQueue { - serverSessionIds[update.localSessionId] = update.serverSessionId ?? serverSessionIds[update.localSessionId] + if let created = update.createdSession { + createdSessions[update.localSessionId] = created + } } cacheRequests() } @@ -210,17 +201,23 @@ private extension OSSessionOperationExecutor { subscriptionId: subscriptionId ) } - return OSRequestUpdateSession( + let update = OSRequestUpdateSession( appId: appId, localSessionId: localSessionId, - serverSessionId: value[OSSessionDeltaKey.serverSessionId] as? String ?? serverSessionIds[localSessionId], activeDuration: value[OSSessionDeltaKey.activeDuration] as? TimeInterval ?? 0, endTime: (value[OSSessionDeltaKey.endTime] as? TimeInterval).map { Date(timeIntervalSince1970: $0) }, identityModel: identityModel, - ownerExternalId: delta.externalId, - onesignalId: onesignalId, - subscriptionId: subscriptionId + ownerExternalId: delta.externalId ) + if let created = createdSessions[localSessionId] { + update.createdSession = created + } else if let serverSessionId = value[OSSessionDeltaKey.serverSessionId] as? String, let onesignalId, let subscriptionId { + // The session record holds what the create sent once it succeeded, possibly in an earlier process. + let created = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) + createdSessions[localSessionId] = created + update.createdSession = created + } + return update } func admit(_ request: OSSessionRequest) { @@ -237,8 +234,7 @@ private extension OSSessionOperationExecutor { /** Folds an update into its session's queued ones, which are all cumulative. Returns false when the - update should not be queued: its session already ended, it can never get a server session ID, or - a queued update already has the same body. + update should not be queued: its session already ended, or it can never get a server session ID. */ func admitUpdate(_ update: OSRequestUpdateSession) -> Bool { let sessionId = update.localSessionId @@ -247,8 +243,7 @@ private extension OSSessionOperationExecutor { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), the session already ended") return false } - update.serverSessionId = update.serverSessionId ?? queuedUpdates.lazy.compactMap(\.serverSessionId).first - guard update.serverSessionId != nil || hasCreate(forSession: sessionId) else { + guard update.createdSession != nil || hasCreate(forSession: sessionId) else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), its session has no create or server session ID") return false } @@ -256,16 +251,6 @@ private extension OSSessionOperationExecutor { update.activeDuration = queuedUpdates.map(\.activeDuration).reduce(update.activeDuration, max) let pending = queuedUpdates.filter { !$0.sentToClient } - update.failedAttempts = pending.map(\.failedAttempts).reduce(update.failedAttempts, max) - update.onesignalId = update.onesignalId ?? pending.lazy.compactMap(\.onesignalId).first - update.subscriptionId = update.subscriptionId ?? pending.lazy.compactMap(\.subscriptionId).first - if pending.count == 1, let queued = pending.first, - queued.activeDuration == update.activeDuration, queued.endTime == update.endTime { - // Keep the queued update and its idempotency key, since the body did not change. - queued.onesignalId = queued.onesignalId ?? update.onesignalId - queued.subscriptionId = queued.subscriptionId ?? update.subscriptionId - return false - } requestQueue.removeAll { request in pending.contains { $0 === request } } // Carry a backoff over, or the replacement would skip it. pending.last.map { retryBackoff.transferWait(from: $0, to: update) } @@ -287,7 +272,7 @@ private extension OSSessionOperationExecutor { let sessionsWithCreate = Set(requestQueue.filter { $0 is OSRequestCreateSession }.map(\.localSessionId)) requestQueue.removeAll { request in guard let update = request as? OSRequestUpdateSession, - update.serverSessionId == nil, + update.createdSession == nil, !sessionsWithCreate.contains(update.localSessionId) else { return false @@ -303,6 +288,10 @@ private extension OSSessionOperationExecutor { private extension OSSessionOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. func processRequestQueue(inBackground: Bool) { + // Without consent the client fails every send with status 0, which would retry forever. + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { + return + } if removeUnsendableRequests() { cacheRequests() } @@ -370,22 +359,41 @@ private extension OSSessionOperationExecutor { } guard let data = response?["data"] as? [String: Any], let serverSessionId = data["session_id"] as? String, - !serverSessionId.isEmpty + !serverSessionId.isEmpty, + let onesignalId = create.onesignalId, + let subscriptionId = create.subscriptionId else { - // Retrying with the same idempotency key returns the session the backend already created. - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor create session response is missing data.session_id") - let statusCode = (response?["httpStatusCode"] as? NSNumber)?.intValue ?? 202 - retry(request, error: OneSignalClientError(code: statusCode, message: "Missing data.session_id", responseHeaders: nil, response: response, underlyingError: nil)) + retryMissingSessionId(create, response: response) return } requestQueue.removeAll { $0 === request } - serverSessionIds[create.localSessionId] = serverSessionId + let created = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) + createdSessions[create.localSessionId] = created for case let update as OSRequestUpdateSession in requestQueue where update.localSessionId == create.localSessionId { - update.serverSessionId = update.serverSessionId ?? serverSessionId + update.createdSession = created } // Updates wait out the post-create delay, so the backend has the session first. newRecordsState.add(serverSessionId) - OSSessionService.shared.setServerSessionId(serverSessionId, forSessionId: create.localSessionId) + OSSessionService.shared.setCreatedSession( + forSessionId: create.localSessionId, + serverSessionId: serverSessionId, + onesignalId: onesignalId, + subscriptionId: subscriptionId + ) + cacheRequests() + } + + /// Retrying with the same idempotency key should return the session the backend already created. + func retryMissingSessionId(_ create: OSRequestCreateSession, response: [AnyHashable: Any]?) { + create.missingSessionIdAttempts += 1 + guard create.missingSessionIdAttempts < Self.maxMissingSessionIdAttempts else { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(create), its responses never had a session_id") + drop(create) + cacheRequests() + return + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor create session response is missing data.session_id") + retry(create, error: OneSignalClientError(code: 202, message: "Missing data.session_id", responseHeaders: nil, response: response, underlyingError: nil)) cacheRequests() } @@ -394,14 +402,9 @@ private extension OSSessionOperationExecutor { let code = error.code if (200..<300).contains(code) { // `OneSignalClient` reports a 2xx whose body failed to parse as a failure. - if request is OSRequestCreateSession { - retry(request, error: error) - } else { - requestQueue.removeAll { $0 === request } - cacheRequests() - } + handleSuccess(request, response: nil) } else if code == 0 || code == 408 || code == 429 || code >= 500 { - // 0 means no HTTP response: no network, timeout, or missing privacy consent. + // 0 means no HTTP response: no network or a timeout. retry(request, error: error) } else { // Any other 4xx, or a missing app ID, which the client reports as a negative code. @@ -411,20 +414,8 @@ private extension OSSessionOperationExecutor { } } - /// Backs off through the shared `OSRequestRetryBackoff`. Only attempts the backend answered count - /// toward the limit, so a Request made offline keeps retrying until the network returns. func retry(_ request: OSSessionRequest, error: OneSignalClientError) { - request.sentToClient = false - if error.code != 0 { - request.failedAttempts += 1 - } - guard request.failedAttempts < Self.maxFailedAttempts else { - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(request) after too many failures") - drop(request) - cacheRequests() - return - } retryBackoff.recordFailure(request, error: error) - cacheRequests() + request.sentToClient = false } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift index b9c035faa..741914154 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -28,6 +28,26 @@ import Foundation import OneSignalCore import OneSignalOSCore +/// Keys of the value an `OS_CREATE_SESSION_DELTA` or `OS_UPDATE_SESSION_DELTA` carries. The Delta's +/// `property` is the local session ID. +enum OSSessionDeltaKey { + static let appId = "app_id" + static let startTime = "start_time" + static let directAttributionId = "direct_attribution_id" + static let activeDuration = "active_duration" + static let endTime = "end_time" + static let onesignalId = "onesignal_id" + static let subscriptionId = "subscription_id" + static let serverSessionId = "server_session_id" +} + +/// What a session's create sent and got back. Its updates must carry exactly these IDs. +struct OSCreatedSession { + let serverSessionId: String + let onesignalId: String + let subscriptionId: String +} + /// RFC 3339 in UTC with milliseconds, e.g. `2023-11-14T22:13:20.000Z`. enum OSSessionTimestamp { private static let formatter: ISO8601DateFormatter = { @@ -48,20 +68,6 @@ protocol OSSessionRequest: OSUserRequest { /// `OSSessionRecord.sessionId`, which stands in for the backend session ID until the create succeeds. var localSessionId: String { get } var identityModel: OSIdentityModel { get set } - /// Failed attempts the backend answered, persisted so the limit holds across restarts. - var failedAttempts: Int { get set } -} - -/// The session's pinned user, falling back to the identity model once the backend creates it. -/// The push subscription is the same device subscription across logins, so it falls back to the -/// current one. -private func resolvedIds(onesignalId: String?, subscriptionId: String?, identityModel: OSIdentityModel) -> (String, String)? { - guard let onesignalId = onesignalId ?? identityModel.onesignalId, - let subscriptionId = subscriptionId ?? OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId - else { - return nil - } - return (onesignalId, subscriptionId) } /// `POST apps/{app_id}/sessions` @@ -74,15 +80,16 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { var identityModel: OSIdentityModel /// See the ownership convention in `OSUserRequest.swift`. let ownerExternalId: String? + /// Fixed by the first send, since the backend does not allow them to change. private(set) var onesignalId: String? private(set) var subscriptionId: String? - var failedAttempts = 0 + var missingSessionIdAttempts = 0 /// The sessions API takes no user JWT. var sendsUnsigned: Bool { true } override var description: String { - "" + "" } init( @@ -107,8 +114,11 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { self.method = POST } + /// Falls back to the user once the backend creates it. The push subscription is the same device + /// subscription across logins, so it falls back to the current one. func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { - guard let (onesignalId, subscriptionId) = resolvedIds(onesignalId: onesignalId, subscriptionId: subscriptionId, identityModel: identityModel), + guard let onesignalId = onesignalId ?? identityModel.onesignalId, + let subscriptionId = subscriptionId ?? OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId, newRecordsState.canAccess(onesignalId), newRecordsState.canAccess(subscriptionId), auth.authorize(self) @@ -140,7 +150,7 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(onesignalId, forKey: "onesignalId") coder.encode(subscriptionId, forKey: "subscriptionId") - coder.encode(failedAttempts, forKey: "failedAttempts") + coder.encode(missingSessionIdAttempts, forKey: "missingSessionIdAttempts") coder.encode(timestamp, forKey: "timestamp") } @@ -161,7 +171,7 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.onesignalId = coder.decodeObject(forKey: "onesignalId") as? String self.subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String - self.failedAttempts = coder.decodeInteger(forKey: "failedAttempts") + self.missingSessionIdAttempts = coder.decodeInteger(forKey: "missingSessionIdAttempts") super.init() self.method = POST self.timestamp = timestamp @@ -173,8 +183,8 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { var sentToClient = false let appId: String let localSessionId: String - /// Set once the session's create succeeds. - var serverSessionId: String? + /// Set once the session's create succeeds. Until then the update waits. + var createdSession: OSCreatedSession? /// Cumulative for the whole session, so a retried or reordered update cannot double count. var activeDuration: TimeInterval /// Set only on the update that ends the session. @@ -182,11 +192,8 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { var identityModel: OSIdentityModel /// See the ownership convention in `OSUserRequest.swift`. let ownerExternalId: String? - var onesignalId: String? - var subscriptionId: String? /// Generated once and persisted, so every retry, including after a restart, is deduplicated by the backend. let idempotencyKey: String - var failedAttempts = 0 /// The sessions API takes no user JWT. var sendsUnsigned: Bool { true } @@ -194,29 +201,23 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { var isEnd: Bool { endTime != nil } override var description: String { - "" + "" } init( appId: String, localSessionId: String, - serverSessionId: String?, activeDuration: TimeInterval, endTime: Date?, identityModel: OSIdentityModel, - ownerExternalId: String?, - onesignalId: String?, - subscriptionId: String? + ownerExternalId: String? ) { self.appId = appId self.localSessionId = localSessionId - self.serverSessionId = serverSessionId self.activeDuration = activeDuration self.endTime = endTime self.identityModel = identityModel self.ownerExternalId = ownerExternalId - self.onesignalId = onesignalId - self.subscriptionId = subscriptionId self.idempotencyKey = UUID().uuidString super.init() self.method = PATCH @@ -224,19 +225,16 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { /// Waits out the post-create delay on the server session ID, so the backend has the session first. func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { - guard let serverSessionId, - let sessionIdSegment = OSUrlPath.segment(serverSessionId), - newRecordsState.canAccess(serverSessionId), - let (onesignalId, subscriptionId) = resolvedIds(onesignalId: onesignalId, subscriptionId: subscriptionId, identityModel: identityModel), + guard let createdSession, + let sessionIdSegment = OSUrlPath.segment(createdSession.serverSessionId), + newRecordsState.canAccess(createdSession.serverSessionId), auth.authorize(self) else { return false } - self.onesignalId = onesignalId - self.subscriptionId = subscriptionId var parameters: [String: Any] = [ - "onesignal_id": onesignalId, - "subscription_id": subscriptionId, + "onesignal_id": createdSession.onesignalId, + "subscription_id": createdSession.subscriptionId, "duration_seconds": Int64(activeDuration), "idempotency_key": idempotencyKey ] @@ -251,15 +249,14 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { func encode(with coder: NSCoder) { coder.encode(appId, forKey: "appId") coder.encode(localSessionId, forKey: "localSessionId") - coder.encode(serverSessionId, forKey: "serverSessionId") + coder.encode(createdSession?.serverSessionId, forKey: "serverSessionId") + coder.encode(createdSession?.onesignalId, forKey: "onesignalId") + coder.encode(createdSession?.subscriptionId, forKey: "subscriptionId") coder.encode(activeDuration, forKey: "activeDuration") coder.encode(endTime, forKey: "endTime") coder.encode(identityModel, forKey: "identityModel") coder.encode(ownerExternalId, forKey: "ownerExternalId") - coder.encode(onesignalId, forKey: "onesignalId") - coder.encode(subscriptionId, forKey: "subscriptionId") coder.encode(idempotencyKey, forKey: "idempotencyKey") - coder.encode(failedAttempts, forKey: "failedAttempts") coder.encode(timestamp, forKey: "timestamp") } @@ -274,15 +271,16 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { } self.appId = appId self.localSessionId = localSessionId - self.serverSessionId = coder.decodeObject(forKey: "serverSessionId") as? String + if let serverSessionId = coder.decodeObject(forKey: "serverSessionId") as? String, + let onesignalId = coder.decodeObject(forKey: "onesignalId") as? String, + let subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String { + self.createdSession = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) + } self.activeDuration = coder.decodeDouble(forKey: "activeDuration") self.endTime = coder.decodeObject(forKey: "endTime") as? Date self.identityModel = identityModel self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String - self.onesignalId = coder.decodeObject(forKey: "onesignalId") as? String - self.subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String self.idempotencyKey = idempotencyKey - self.failedAttempts = coder.decodeInteger(forKey: "failedAttempts") super.init() self.method = PATCH self.timestamp = timestamp diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index c0971308d..15882dab2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -82,6 +82,7 @@ final class OSSessionOperationExecutorTests: XCTestCase { } override func tearDownWithError() throws { + OSPrivacyConsentController.setRequiresPrivacyConsent(false) OSSessionService.reset() OneSignalCoreMocks.clearUserDefaults() } @@ -103,10 +104,20 @@ final class OSSessionOperationExecutorTests: XCTestCase { return delta(OS_CREATE_SESSION_DELTA, session: session, value: value, identityModel: identityModel) } - private func updateDelta(session: String = "session-1", duration: TimeInterval, endTime: TimeInterval? = nil, serverSessionId: String? = nil) -> OSDelta { + /// A `serverSessionId` stands for a session record its create already filled, so it comes with the + /// create's IDs. + private func updateDelta( + session: String = "session-1", + duration: TimeInterval, + endTime: TimeInterval? = nil, + serverSessionId: String? = nil, + subscriptionId: String = testPushSubId + ) -> OSDelta { var value: [String: Any] = [ OSSessionDeltaKey.appId: "test-app-id", - OSSessionDeltaKey.activeDuration: duration + OSSessionDeltaKey.activeDuration: duration, + OSSessionDeltaKey.onesignalId: onesignalId, + OSSessionDeltaKey.subscriptionId: subscriptionId ] value[OSSessionDeltaKey.endTime] = endTime value[OSSessionDeltaKey.serverSessionId] = serverSessionId @@ -200,7 +211,47 @@ final class OSSessionOperationExecutorTests: XCTestCase { flush(executor, updateDelta(duration: 10)) - XCTAssertEqual((client.requests.last as? OSRequestUpdateSession)?.serverSessionId, "server-1") + XCTAssertEqual((client.requests.last as? OSRequestUpdateSession)?.createdSession?.serverSessionId, "server-1") + } + + func testUpdatesCarryTheIdsTheCreateSent() throws { + OSSessionService.shared.startNewSession(userProvider: OneSignalUserManagerImpl.sharedInstance) + let sessionId = try XCTUnwrap(OSSessionService.shared.currentRecord?.sessionId) + let executor = makeExecutor() + flush(executor, createDelta(session: sessionId), updateDelta(session: sessionId, duration: 10, subscriptionId: "other-subscription-id")) + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = "new-subscription-id" + + createSucceeds(executor) + flush(executor, updateDelta(session: sessionId, duration: 20, subscriptionId: "other-subscription-id")) + + let update = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(update.parameters?["onesignal_id"] as? String, onesignalId) + XCTAssertEqual(update.parameters?["subscription_id"] as? String, testPushSubId) + let record = try XCTUnwrap(OSSessionService.shared.currentRecord) + XCTAssertEqual(record.serverSessionId, "server-1") + XCTAssertEqual(record.subscriptionId, testPushSubId) + } + + func testNothingIsSentBeforePrivacyConsent() { + OSPrivacyConsentController.setRequiresPrivacyConsent(true) + let executor = makeExecutor() + + flush(executor, createDelta()) + let manager = OneSignalUserManagerImpl.sharedInstance + manager.enqueueSessionCreate(OSSessionRecord( + sessionId: "session-2", + startTime: 1_700_000_000, + activeDuration: 0, + usesSessionsApi: true, + identityModelId: user.identityModel.modelId, + onesignalId: nil, + subscriptionId: nil, + serverSessionId: nil + )) + manager.operationRepo.dispatchQueue.sync {} + + XCTAssertTrue(client.requests.isEmpty) + XCTAssertFalse(manager.operationRepo.deltaQueue.contains { $0.name == OS_CREATE_SESSION_DELTA }) } // MARK: - Coalescing @@ -225,15 +276,6 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertTrue(updates.first?.isEnd == true) } - func testUnchangedUpdateKeepsTheQueuedIdempotencyKey() { - let executor = makeExecutor() - let first = flush(executor, createDelta(), updateDelta(duration: 10)).compactMap { $0 as? OSRequestUpdateSession } - - let second = flush(executor, updateDelta(duration: 10)).compactMap { $0 as? OSRequestUpdateSession } - - XCTAssertEqual(second.map(\.idempotencyKey), first.map(\.idempotencyKey)) - } - func testUpdateInFlightCountsTowardTheHighestDuration() { let executor = makeExecutor() flush(executor, updateDelta(duration: 30, serverSessionId: "server-1")) @@ -278,7 +320,6 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertEqual(client.requests.count, 2) let retried = try XCTUnwrap(client.requests.last as? OSRequestCreateSession) XCTAssertEqual(retried.parameters?["idempotency_key"] as? String, "session-1") - XCTAssertEqual(retried.failedAttempts, 1) } func testRetryAfterIsHonored() { @@ -333,50 +374,43 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertEqual((client.requests.last as? OSRequestUpdateSession)?.activeDuration, 20) } - func testNoResponseDoesNotCountTowardTheAttemptLimit() { - let executor = makeExecutor() - flush(executor, createDelta()) - - for _ in 0.. Date: Wed, 14 Oct 2026 17:15:11 -0700 Subject: [PATCH 03/10] chore: [SDK-5379] move startNewSession to the session extension for SwiftLint file length Co-Authored-By: Claude Opus 5.5 --- .../OneSignalUserManagerImpl+Session.swift | 25 +++++++++++++++++++ .../Source/OneSignalUserManagerImpl.swift | 23 ----------------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index 409d061c6..cbc88cc36 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -28,6 +28,31 @@ import OneSignalCore import OneSignalOSCore +extension OneSignalUserManagerImpl { + @objc + public func startNewSession() { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManagerImpl starting new session") + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "_startNewSession") else { + return + } + start() + + userExecutor!.executePendingRequestsOnNewSession() + operationRepo.paused = false + updatePropertiesDeltas(property: .session_count, value: 1, flush: true) + OSSessionService.shared.startNewSession(userProvider: self) + + // Fetch the user's data if there is a onesignal_id + if let onesignalId = onesignalId { + userExecutor!.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: user.identityModel, onNewSession: true) + } else { + // It is possible to init a user from cache who is missing the onesignalId + // This can happen if any createUser or identifyUser requests are cached + OneSignalLog.onesignalLog(.LL_WARN, message: "OneSignalUserManagerImpl.startNewSession() is unable to fetch user with External ID \(externalId ?? "nil") due to null OneSignal ID") + } + } +} + extension OneSignalUserManagerImpl: OSSessionUserProvider { public var sessionCurrentUser: OSSessionUser { let user = _user diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 4f5f91493..3c745e308 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -696,29 +696,6 @@ extension OneSignalUserManagerImpl { // MARK: - Sessions extension OneSignalUserManagerImpl { - @objc - public func startNewSession() { - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManagerImpl starting new session") - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "_startNewSession") else { - return - } - start() - - userExecutor!.executePendingRequestsOnNewSession() - operationRepo.paused = false - updatePropertiesDeltas(property: .session_count, value: 1, flush: true) - OSSessionService.shared.startNewSession(userProvider: self) - - // Fetch the user's data if there is a onesignal_id - if let onesignalId = onesignalId { - userExecutor!.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: user.identityModel, onNewSession: true) - } else { - // It is possible to init a user from cache who is missing the onesignalId - // This can happen if any createUser or identifyUser requests are cached - OneSignalLog.onesignalLog(.LL_WARN, message: "OneSignalUserManagerImpl.startNewSession() is unable to fetch user with External ID \(externalId ?? "nil") due to null OneSignal ID") - } - } - /// This method accepts properties updates that not driven by model changes. /// It enqueues an OSDelta to the Operation Repo. /// From 90c84033cf2208952711bdebaae0e37edb8b1d07 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 14 Oct 2026 17:32:45 -0700 Subject: [PATCH 04/10] fix: [SDK-5379] discard updates after a sent end and persist a create's sent IDs An end leaves the queue once sent or dropped, so remember ended sessions and discard later updates for them. Persist the queue after sending a create, so a replay after a kill reuses the IDs it was sent with. Co-Authored-By: Claude Opus 5.5 --- .../OSSessionOperationExecutor.swift | 18 +++++++++- .../OSSessionOperationExecutorTests.swift | 35 +++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index 2a0c522f8..9c5881dfc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -43,6 +43,8 @@ class OSSessionOperationExecutor: OSOperationExecutor { private var requestQueue: [OSSessionRequest] = [] /// By local session ID, for an update built after its create already succeeded. private var createdSessions: [String: OSCreatedSession] = [:] + /// By local session ID. An end leaves the queue once sent or dropped, but its session stays ended. + private var endedSessions: Set = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing private let retryBackoff: OSRequestRetryBackoff @@ -239,7 +241,7 @@ private extension OSSessionOperationExecutor { func admitUpdate(_ update: OSRequestUpdateSession) -> Bool { let sessionId = update.localSessionId let queuedUpdates = requestQueue.compactMap { $0 as? OSRequestUpdateSession }.filter { $0.localSessionId == sessionId } - guard !queuedUpdates.contains(where: \.isEnd) else { + guard !endedSessions.contains(sessionId), !queuedUpdates.contains(where: \.isEnd) else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), the session already ended") return false } @@ -264,10 +266,17 @@ private extension OSSessionOperationExecutor { /// Removes a Request that will not be sent. A dropped create takes its session's updates with it, /// since they could never get a server session ID. func drop(_ request: OSSessionRequest) { + markEndedIfEnd(request) requestQueue.removeAll { $0 === request } removeOrphanedUpdates() } + func markEndedIfEnd(_ request: OSSessionRequest) { + if let update = request as? OSRequestUpdateSession, update.isEnd { + endedSessions.insert(update.localSessionId) + } + } + func removeOrphanedUpdates() { let sessionsWithCreate = Set(requestQueue.filter { $0 is OSRequestCreateSession }.map(\.localSessionId)) requestQueue.removeAll { request in @@ -296,6 +305,7 @@ private extension OSSessionOperationExecutor { cacheRequests() } var sessionsSeen = Set() + var sentCreate = false for request in requestQueue { guard sessionsSeen.insert(request.localSessionId).inserted, !request.sentToClient, @@ -304,8 +314,13 @@ private extension OSSessionOperationExecutor { else { continue } + sentCreate = sentCreate || request is OSRequestCreateSession executeRequest(request, inBackground: inBackground) } + if sentCreate { + // Persists the IDs the create was just sent with, so a replay after a kill cannot change them. + cacheRequests() + } } /// Drops Requests recorded for another app, and those past their age limit (see `OSRequestAging`). @@ -353,6 +368,7 @@ private extension OSSessionOperationExecutor { func handleSuccess(_ request: OSSessionRequest, response: [AnyHashable: Any]?) { guard let create = request as? OSRequestCreateSession else { + markEndedIfEnd(request) requestQueue.removeAll { $0 === request } cacheRequests() return diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 15882dab2..e14f146ad 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -265,6 +265,17 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertEqual(updates.map(\.activeDuration), [10]) } + func testUpdateAfterASentEndIsDiscarded() { + let executor = makeExecutor() + flush(executor, updateDelta(duration: 20, endTime: 1_700_000_100, serverSessionId: "server-1")) + client.succeedLast() + + let queued = flush(executor, updateDelta(duration: 30, serverSessionId: "server-1")) + + XCTAssertTrue(queued.isEmpty) + XCTAssertEqual(client.requests.count, 1) + } + func testEndReplacesQueuedUpdatesAndLaterUpdatesAreDiscarded() { let executor = makeExecutor() @@ -422,7 +433,11 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertTrue(executor.queuedRequests.isEmpty) } - // MARK: - Persistence and ownership +} + +// MARK: - Persistence and ownership + +extension OSSessionOperationExecutorTests { func testQueuedRequestsSurviveARestartWithTheSameKeys() throws { let executor = makeExecutor() @@ -436,6 +451,18 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertEqual((restored.last as? OSRequestUpdateSession)?.idempotencyKey, key) } + func testACreateKeepsItsSentIdsAcrossAKillBeforeTheResponse() throws { + let executor = makeExecutor() + flush(executor, createDelta()) + XCTAssertEqual(client.requests.count, 1) + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = "new-subscription-id" + + let restored = try XCTUnwrap(makeExecutor().queuedRequests.first as? OSRequestCreateSession) + + XCTAssertEqual(restored.onesignalId, onesignalId) + XCTAssertEqual(restored.subscriptionId, testPushSubId) + } + func testRequestForAnotherAppIsDropped() { user.identityModel.removeAliases([OS_ONESIGNAL_ID]) let executor = makeExecutor() @@ -468,7 +495,11 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertEqual(executor.queuedRequests.map(\.localSessionId), ["identified", "created-anonymous"]) } - // MARK: - Enqueueing +} + +// MARK: - Enqueueing + +extension OSSessionOperationExecutorTests { func testEnqueueIsANoOpWhenTheSessionDoesNotUseTheSessionsApi() { let manager = OneSignalUserManagerImpl.sharedInstance From 6f23fc153734a859db7ef6ffc929aed1985efb40 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 14 Oct 2026 22:54:22 -0700 Subject: [PATCH 05/10] fix: [SDK-5379] retry a session 404 while its record is new Co-Authored-By: Claude Opus 5.5 --- .../Source/OneSignalCommonDefines.h | 4 ++ .../Source/OSNewRecordsState.swift | 14 ++++- .../OSSessionOperationExecutor.swift | 14 +++++ .../OSSessionOperationExecutorTests.swift | 61 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 22aa524cd..f319d2cd7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -279,6 +279,9 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP incorrectlyget a 404 when attempting a GET or PATCH REST API call on something just after it is created. */ #define OP_REPO_POST_CREATE_DELAY_SECONDS 3 + + // How long after a record is created a 404 on it is treated as replication lag and retried. + #define OP_REPO_POST_CREATE_RETRY_UP_TO_SECONDS 60 #else // Test defines for API Client #define REATTEMPT_DELAY 0.004 @@ -301,6 +304,7 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP // Reduce delay in tests #define OP_REPO_POST_CREATE_DELAY_SECONDS 0 + #define OP_REPO_POST_CREATE_RETRY_UP_TO_SECONDS 60 #endif // A max timeout for a request, which might include multiple reattempts diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSNewRecordsState.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSNewRecordsState.swift index 2fc1e322f..badfbb0f7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSNewRecordsState.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSNewRecordsState.swift @@ -40,6 +40,8 @@ public class OSNewRecordsState { */ private var records: [String: Date] = [:] private let lock = NSRecursiveLock() + /// Overridden by tests to move past the retry window. + var now: () -> Date = { Date() } public init() { } @@ -62,7 +64,17 @@ public class OSNewRecordsState { let minimumTime = timeLastMovedOrCreated.addingTimeInterval(TimeInterval(OP_REPO_POST_CREATE_DELAY_SECONDS)) - return Date() >= minimumTime + return now() >= minimumTime + } + } + + /// Whether `key` was created recently enough that a 404 on it may be backend replication lag. + public func isInMissingRetryWindow(_ key: String) -> Bool { + lock.withLock { + guard let timeLastMovedOrCreated = records[key] else { + return false + } + return now().timeIntervalSince(timeLastMovedOrCreated) <= TimeInterval(OP_REPO_POST_CREATE_RETRY_UP_TO_SECONDS) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index 9c5881dfc..376362374 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -422,6 +422,9 @@ private extension OSSessionOperationExecutor { } else if code == 0 || code == 408 || code == 429 || code >= 500 { // 0 means no HTTP response: no network or a timeout. retry(request, error: error) + } else if code == 404 && isInMissingRetryWindow(request) { + // The backend may not have the just-created record yet. + retry(request, error: error) } else { // Any other 4xx, or a missing app ID, which the client reports as a negative code. OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(request) after a \(code)") @@ -430,6 +433,17 @@ private extension OSSessionOperationExecutor { } } + func isInMissingRetryWindow(_ request: OSSessionRequest) -> Bool { + switch request { + case let create as OSRequestCreateSession: + return [create.onesignalId, create.subscriptionId].contains { $0.map(newRecordsState.isInMissingRetryWindow) ?? false } + case let update as OSRequestUpdateSession: + return update.createdSession.map { newRecordsState.isInMissingRetryWindow($0.serverSessionId) } ?? false + default: + return false + } + } + func retry(_ request: OSSessionRequest, error: OneSignalClientError) { retryBackoff.recordFailure(request, error: error) request.sentToClient = false diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index e14f146ad..646321e7f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -435,6 +435,67 @@ final class OSSessionOperationExecutorTests: XCTestCase { } +// MARK: - 404 retry window + +extension OSSessionOperationExecutorTests { + func testCreate404ForANewUserIsRetried() { + newRecordsState.add(onesignalId) + let executor = makeExecutor() + flush(executor, createDelta()) + + fail(executor, code: 404) + uptime += OSRequestRetryBackoff.baseDelaySeconds + flush(executor) + + XCTAssertEqual(client.requests.count, 2) + } + + func testCreate404ForAnOldUserIsDropped() { + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 10)) + + fail(executor, code: 404) + + XCTAssertTrue(executor.queuedRequests.isEmpty) + } + + func testUpdate404ForANewSessionIsRetried() { + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 10)) + createSucceeds(executor) + flush(executor) + + fail(executor, code: 404) + uptime += OSRequestRetryBackoff.baseDelaySeconds + flush(executor) + + XCTAssertEqual(client.requests.count, 3) + XCTAssertTrue(client.requests.last is OSRequestUpdateSession) + } + + func testUpdate404AfterTheWindowIsDropped() { + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 10)) + createSucceeds(executor) + flush(executor) + + newRecordsState.now = { Date().addingTimeInterval(TimeInterval(OP_REPO_POST_CREATE_RETRY_UP_TO_SECONDS) + 1) } + fail(executor, code: 404) + + XCTAssertTrue(executor.queuedRequests.isEmpty) + } + + func test409IsDroppedEvenForANewUser() { + newRecordsState.add(onesignalId) + let executor = makeExecutor() + flush(executor, createDelta()) + + fail(executor, code: 409) + + XCTAssertTrue(executor.queuedRequests.isEmpty) + } +} + // MARK: - Persistence and ownership extension OSSessionOperationExecutorTests { From 18af6ea36097d004398a4fffc7399ca55a862c54 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 15 Oct 2026 11:32:54 -0700 Subject: [PATCH 06/10] fix: [SDK-5379] persist created sessions and age out session requests An update built while its create was in flight has no server session ID. If the process is killed after the create succeeds, the in-memory map was lost and the update was discarded. Persist it, capped at the newest 100. Session requests for the current user now age out after 30 days. Co-Authored-By: Claude Opus 5.5 --- .../Source/OneSignalCommonDefines.h | 1 + .../OSSessionOperationExecutor.swift | 59 +++++++++++++++---- .../OneSignalUser/Source/OSRequestAging.swift | 1 + .../OSSessionOperationExecutorTests.swift | 27 ++++++++- 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index f319d2cd7..616bcd131 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -390,6 +390,7 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP // Session Executor #define OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY @"OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY" #define OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY @"OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY" +#define OS_SESSION_EXECUTOR_CREATED_SESSIONS_KEY @"OS_SESSION_EXECUTOR_CREATED_SESSIONS_KEY" // A codeable value larger than this is not written, and a cached queue blob larger than it is dropped without // decoding it. Real queues are a few KB. CFPreferences rejects a value of 4 MB or more on iOS. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index 376362374..1de17c00d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -41,8 +41,9 @@ class OSSessionOperationExecutor: OSOperationExecutor { var supportedDeltas: [String] = [OS_CREATE_SESSION_DELTA, OS_UPDATE_SESSION_DELTA] private var deltaQueue: [OSDelta] = [] private var requestQueue: [OSSessionRequest] = [] - /// By local session ID, for an update built after its create already succeeded. - private var createdSessions: [String: OSCreatedSession] = [:] + /// By local session ID, oldest first, for an update built after its create already succeeded. + /// Persisted, since that update may still be a Delta when the process is killed. + private var createdSessions: [(localSessionId: String, session: OSCreatedSession)] = [] /// By local session ID. An end leaves the queue once sent or dropped, but its session stays ended. private var endedSessions: Set = [] private let newRecordsState: OSNewRecordsState @@ -63,10 +64,25 @@ class OSSessionOperationExecutor: OSOperationExecutor { self.auth = auth self.retryBackoff = retryBackoff self.nowProvider = nowProvider + uncacheCreatedSessions() uncacheDeltas() uncacheRequests() } + private func uncacheCreatedSessions() { + let cached = OneSignalUserDefaults.initShared().getSavedObject(forKey: OS_SESSION_EXECUTOR_CREATED_SESSIONS_KEY, defaultValue: []) as? [[String: String]] ?? [] + createdSessions = cached.compactMap { entry in + guard let localSessionId = entry["localSessionId"], + let serverSessionId = entry["serverSessionId"], + let onesignalId = entry["onesignalId"], + let subscriptionId = entry["subscriptionId"] + else { + return nil + } + return (localSessionId, OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId)) + } + } + private func uncacheDeltas() { guard var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor error encountered reading from cache for \(OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY)") @@ -103,11 +119,6 @@ class OSSessionOperationExecutor: OSOperationExecutor { return false } removeOrphanedUpdates() - for case let update as OSRequestUpdateSession in requestQueue { - if let created = update.createdSession { - createdSessions[update.localSessionId] = created - } - } cacheRequests() } @@ -175,6 +186,18 @@ class OSSessionOperationExecutor: OSOperationExecutor { private func cacheRequests() { OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY, withValue: requestQueue) } + + private func cacheCreatedSessions() { + let entries = createdSessions.map { entry in + [ + "localSessionId": entry.localSessionId, + "serverSessionId": entry.session.serverSessionId, + "onesignalId": entry.session.onesignalId, + "subscriptionId": entry.session.subscriptionId + ] + } + OneSignalUserDefaults.initShared().saveObject(forKey: OS_SESSION_EXECUTOR_CREATED_SESSIONS_KEY, withValue: entries) + } } // MARK: - Queueing @@ -211,17 +234,31 @@ private extension OSSessionOperationExecutor { identityModel: identityModel, ownerExternalId: delta.externalId ) - if let created = createdSessions[localSessionId] { + if let created = createdSession(forSession: localSessionId) { update.createdSession = created } else if let serverSessionId = value[OSSessionDeltaKey.serverSessionId] as? String, let onesignalId, let subscriptionId { // The session record holds what the create sent once it succeeded, possibly in an earlier process. let created = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) - createdSessions[localSessionId] = created + recordCreated(created, forSession: localSessionId) update.createdSession = created } return update } + func createdSession(forSession sessionId: String) -> OSCreatedSession? { + createdSessions.last { $0.localSessionId == sessionId }?.session + } + + func recordCreated(_ created: OSCreatedSession, forSession sessionId: String) { + createdSessions.removeAll { $0.localSessionId == sessionId } + createdSessions.append((sessionId, created)) + // Only updates built while the create was in flight need this, so the newest are enough. + if createdSessions.count > Self.maxQueuedRequests { + createdSessions.removeFirst(createdSessions.count - Self.maxQueuedRequests) + } + cacheCreatedSessions() + } + func admit(_ request: OSSessionRequest) { if let update = request as? OSRequestUpdateSession, !admitUpdate(update) { return @@ -333,7 +370,7 @@ private extension OSSessionOperationExecutor { if let currentAppId, request.appId != currentAppId { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(request), it belongs to another app") drop(request) - } else if let age = OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: nil, now: now, currentExternalId: currentExternalId) { + } else if let age = OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: OSRequestAging.sessionRequestMaxAge, now: now, currentExternalId: currentExternalId) { OSRequestAging.logDrop(of: "\(type(of: request))", owner: request.ownerExternalId, age: age, executor: "OSSessionOperationExecutor") drop(request) } @@ -384,7 +421,7 @@ private extension OSSessionOperationExecutor { } requestQueue.removeAll { $0 === request } let created = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) - createdSessions[create.localSessionId] = created + recordCreated(created, forSession: create.localSessionId) for case let update as OSRequestUpdateSession in requestQueue where update.localSessionId == create.localSessionId { update.createdSession = created } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift index 835acd0a0..7aeade758 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift @@ -35,6 +35,7 @@ enum OSRequestAging { static let propertyRequestMaxAge: TimeInterval = 60 * 60 * 24 * 90 // 90 days static let nonCurrentUserRequestMaxAge: TimeInterval = 60 * 60 * 24 * 30 // 30 days static let customEventRequestMaxAge: TimeInterval = 60 * 60 * 24 * 30 // 30 days + static let sessionRequestMaxAge: TimeInterval = 60 * 60 * 24 * 30 // 30 days /// The `external_id` of the current user, nil while there is none. Reads `_user`, since `user` calls /// `start()` and would deadlock at uncache. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 646321e7f..6c9ac11ad 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -87,11 +87,12 @@ final class OSSessionOperationExecutorTests: XCTestCase { OneSignalCoreMocks.clearUserDefaults() } - private func makeExecutor() -> OSSessionOperationExecutor { + private func makeExecutor(now: @escaping () -> Date = { Date() }) -> OSSessionOperationExecutor { OSSessionOperationExecutor( newRecordsState: newRecordsState, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth, - retryBackoff: retryBackoff + retryBackoff: retryBackoff, + nowProvider: now ) } @@ -524,6 +525,28 @@ extension OSSessionOperationExecutorTests { XCTAssertEqual(restored.subscriptionId, testPushSubId) } + func testUpdateBuiltBeforeItsCreateSucceededSurvivesAKill() throws { + let executor = makeExecutor() + flush(executor, createDelta()) + executor.enqueueDelta(updateDelta(duration: 10)) + executor.cacheDeltaQueue() + createSucceeds(executor) + + flush(makeExecutor()) + + let update = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(update.path, "apps/test-app-id/sessions/server-1") + } + + func testCurrentUsersRequestIsDroppedPastTheSessionAgeLimit() { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + flush(makeExecutor(), createDelta()) + + let later = Date().addingTimeInterval(OSRequestAging.sessionRequestMaxAge + 1) + + XCTAssertTrue(flush(makeExecutor(now: { later })).isEmpty) + } + func testRequestForAnotherAppIsDropped() { user.identityModel.removeAliases([OS_ONESIGNAL_ID]) let executor = makeExecutor() From 0e44d50ddcac9948289e932a3cceb0da575f1a90 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 16 Oct 2026 14:32:32 -0700 Subject: [PATCH 07/10] chore: [SDK-5379] log why a session delta is dropped Co-Authored-By: Claude Opus 5.5 --- .../Source/OneSignalUserManagerImpl+Session.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index cbc88cc36..ae197e567 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -83,11 +83,15 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { } private func enqueueSessionDelta(_ name: String, record: OSSessionRecord, value: [String: Any]) { - guard record.usesSessionsApi, - let identityModelId = record.identityModelId, - let identityModel = identityModelRepo.get(modelId: identityModelId), - let appId = OneSignalIdentifiers.currentAppId + guard record.usesSessionsApi else { return } + guard let identityModelId = record.identityModelId, + let identityModel = identityModelRepo.get(modelId: identityModelId) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManagerImpl dropped \(name) for session \(record.sessionId), no identity model") + return + } + guard let appId = OneSignalIdentifiers.currentAppId else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManagerImpl dropped \(name) for session \(record.sessionId), no app ID") return } var value = value From c82e19d6055d6fee41d812b3bf74f71887640903 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 16 Oct 2026 14:46:59 -0700 Subject: [PATCH 08/10] refactor: [SDK-5379] keep a session's created and ended state in one place The executor's persisted per-session state is now the only record of a session's create and end. Updates read the created IDs from it when sent, and the session record no longer stores the server session ID. Co-Authored-By: Claude Opus 5.5 --- .../Source/OneSignalCommonDefines.h | 2 +- .../Source/OSSessionService.swift | 20 +-- .../OSSessionServiceTests.swift | 4 +- .../OSSessionOperationExecutor.swift | 165 ++++++++++-------- .../OneSignalUserManagerImpl+Session.swift | 5 +- .../Source/Requests/OSSessionRequests.swift | 11 +- .../OSSessionOperationExecutorTests.swift | 75 ++++---- 7 files changed, 143 insertions(+), 139 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 616bcd131..331f30ea7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -390,7 +390,7 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP // Session Executor #define OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY @"OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY" #define OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY @"OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY" -#define OS_SESSION_EXECUTOR_CREATED_SESSIONS_KEY @"OS_SESSION_EXECUTOR_CREATED_SESSIONS_KEY" +#define OS_SESSION_EXECUTOR_SESSIONS_KEY @"OS_SESSION_EXECUTOR_SESSIONS_KEY" // A codeable value larger than this is not written, and a cached queue blob larger than it is dropped without // decoding it. Real queues are a few KB. CFPreferences rejects a value of 4 MB or more on iOS. diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 99eaf7143..eb565d4df 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -65,8 +65,6 @@ public struct OSSessionRecord: Codable, Equatable { /// Pinned at session start, so it stays the same after a login or user switch. public internal(set) var onesignalId: String? public internal(set) var subscriptionId: String? - /// Nil until the sessions API creates this session. - public internal(set) var serverSessionId: String? } /// Owns the persisted record of the current session. Nothing reads the record while @@ -168,8 +166,7 @@ public final class OSSessionService: NSObject { usesSessionsApi: featureManager().isEnabled(featureKey: FeatureFlag.sdkSessionsV2ApiCutover.key), identityModelId: user.identityModelId, onesignalId: user.onesignalId, - subscriptionId: user.pushSubscriptionId, - serverSessionId: nil + subscriptionId: user.pushSubscriptionId ) stateLock.withLock { self.userProvider = userProvider @@ -248,21 +245,6 @@ public final class OSSessionService: NSObject { persist() } - /// Set once the sessions API creates the session, if it is still the current one. Records the IDs - /// the create sent, which the session's updates must repeat. - public func setCreatedSession(forSessionId sessionId: String, serverSessionId: String, onesignalId: String, subscriptionId: String) { - stateLock.withLock { - loadIfNeeded() - guard record?.sessionId == sessionId, record?.serverSessionId == nil else { - return - } - record?.serverSessionId = serverSessionId - record?.onesignalId = onesignalId - record?.subscriptionId = subscriptionId - persist() - } - } - private func loadIfNeeded() { guard !loaded else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 305655133..f8e414968 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -124,7 +124,7 @@ final class OSSessionServiceTests: XCTestCase { // MARK: - Record - func testNewSessionPinsIdsAndHasNoServerSessionId() { + func testNewSessionPinsIds() { let service = makeForegroundSession() let record = service.currentRecord @@ -132,7 +132,6 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(record?.subscriptionId, "subscription-a") XCTAssertEqual(record?.startTime, 5_000) XCTAssertEqual(record?.activeDuration, 0) - XCTAssertNil(record?.serverSessionId) } func testEachNewSessionGetsANewSessionId() { @@ -190,7 +189,6 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(record?.usesSessionsApi, true) XCTAssertEqual(record?.onesignalId, "onesignal-a") XCTAssertEqual(record?.subscriptionId, "subscription-a") - XCTAssertNil(record?.serverSessionId) } func testClearingTheStoredRecordDropsIt() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index 1de17c00d..985d6b805 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -41,11 +41,9 @@ class OSSessionOperationExecutor: OSOperationExecutor { var supportedDeltas: [String] = [OS_CREATE_SESSION_DELTA, OS_UPDATE_SESSION_DELTA] private var deltaQueue: [OSDelta] = [] private var requestQueue: [OSSessionRequest] = [] - /// By local session ID, oldest first, for an update built after its create already succeeded. - /// Persisted, since that update may still be a Delta when the process is killed. - private var createdSessions: [(localSessionId: String, session: OSCreatedSession)] = [] - /// By local session ID. An end leaves the queue once sent or dropped, but its session stays ended. - private var endedSessions: Set = [] + /// The one place that knows whether a session was created and whether it ended, oldest first and + /// persisted. A session's requests can leave the queue while more of its updates are still coming. + private var sessions: [SessionState] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing private let retryBackoff: OSRequestRetryBackoff @@ -64,23 +62,14 @@ class OSSessionOperationExecutor: OSOperationExecutor { self.auth = auth self.retryBackoff = retryBackoff self.nowProvider = nowProvider - uncacheCreatedSessions() + uncacheSessions() uncacheDeltas() uncacheRequests() } - private func uncacheCreatedSessions() { - let cached = OneSignalUserDefaults.initShared().getSavedObject(forKey: OS_SESSION_EXECUTOR_CREATED_SESSIONS_KEY, defaultValue: []) as? [[String: String]] ?? [] - createdSessions = cached.compactMap { entry in - guard let localSessionId = entry["localSessionId"], - let serverSessionId = entry["serverSessionId"], - let onesignalId = entry["onesignalId"], - let subscriptionId = entry["subscriptionId"] - else { - return nil - } - return (localSessionId, OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId)) - } + private func uncacheSessions() { + let cached = OneSignalUserDefaults.initShared().getSavedObject(forKey: OS_SESSION_EXECUTOR_SESSIONS_KEY, defaultValue: []) as? [[String: Any]] ?? [] + sessions = cached.compactMap(SessionState.init(entry:)) } private func uncacheDeltas() { @@ -187,16 +176,8 @@ class OSSessionOperationExecutor: OSOperationExecutor { OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY, withValue: requestQueue) } - private func cacheCreatedSessions() { - let entries = createdSessions.map { entry in - [ - "localSessionId": entry.localSessionId, - "serverSessionId": entry.session.serverSessionId, - "onesignalId": entry.session.onesignalId, - "subscriptionId": entry.session.subscriptionId - ] - } - OneSignalUserDefaults.initShared().saveObject(forKey: OS_SESSION_EXECUTOR_CREATED_SESSIONS_KEY, withValue: entries) + private func cacheSessions() { + OneSignalUserDefaults.initShared().saveObject(forKey: OS_SESSION_EXECUTOR_SESSIONS_KEY, withValue: sessions.map(\.entry)) } } @@ -212,8 +193,6 @@ private extension OSSessionOperationExecutor { return nil } let localSessionId = delta.property - let onesignalId = value[OSSessionDeltaKey.onesignalId] as? String - let subscriptionId = value[OSSessionDeltaKey.subscriptionId] as? String if delta.name == OS_CREATE_SESSION_DELTA { return OSRequestCreateSession( appId: appId, @@ -222,11 +201,11 @@ private extension OSSessionOperationExecutor { directAttributionId: value[OSSessionDeltaKey.directAttributionId] as? String, identityModel: identityModel, ownerExternalId: delta.externalId, - onesignalId: onesignalId, - subscriptionId: subscriptionId + onesignalId: value[OSSessionDeltaKey.onesignalId] as? String, + subscriptionId: value[OSSessionDeltaKey.subscriptionId] as? String ) } - let update = OSRequestUpdateSession( + return OSRequestUpdateSession( appId: appId, localSessionId: localSessionId, activeDuration: value[OSSessionDeltaKey.activeDuration] as? TimeInterval ?? 0, @@ -234,29 +213,6 @@ private extension OSSessionOperationExecutor { identityModel: identityModel, ownerExternalId: delta.externalId ) - if let created = createdSession(forSession: localSessionId) { - update.createdSession = created - } else if let serverSessionId = value[OSSessionDeltaKey.serverSessionId] as? String, let onesignalId, let subscriptionId { - // The session record holds what the create sent once it succeeded, possibly in an earlier process. - let created = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) - recordCreated(created, forSession: localSessionId) - update.createdSession = created - } - return update - } - - func createdSession(forSession sessionId: String) -> OSCreatedSession? { - createdSessions.last { $0.localSessionId == sessionId }?.session - } - - func recordCreated(_ created: OSCreatedSession, forSession sessionId: String) { - createdSessions.removeAll { $0.localSessionId == sessionId } - createdSessions.append((sessionId, created)) - // Only updates built while the create was in flight need this, so the newest are enough. - if createdSessions.count > Self.maxQueuedRequests { - createdSessions.removeFirst(createdSessions.count - Self.maxQueuedRequests) - } - cacheCreatedSessions() } func admit(_ request: OSSessionRequest) { @@ -278,11 +234,11 @@ private extension OSSessionOperationExecutor { func admitUpdate(_ update: OSRequestUpdateSession) -> Bool { let sessionId = update.localSessionId let queuedUpdates = requestQueue.compactMap { $0 as? OSRequestUpdateSession }.filter { $0.localSessionId == sessionId } - guard !endedSessions.contains(sessionId), !queuedUpdates.contains(where: \.isEnd) else { + guard state(forSession: sessionId)?.ended != true, !queuedUpdates.contains(where: \.isEnd) else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), the session already ended") return false } - guard update.createdSession != nil || hasCreate(forSession: sessionId) else { + guard createdSession(forSession: sessionId) != nil || hasCreate(forSession: sessionId) else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), its session has no create or server session ID") return false } @@ -310,7 +266,7 @@ private extension OSSessionOperationExecutor { func markEndedIfEnd(_ request: OSSessionRequest) { if let update = request as? OSRequestUpdateSession, update.isEnd { - endedSessions.insert(update.localSessionId) + updateState(forSession: update.localSessionId) { $0.ended = true } } } @@ -318,7 +274,7 @@ private extension OSSessionOperationExecutor { let sessionsWithCreate = Set(requestQueue.filter { $0 is OSRequestCreateSession }.map(\.localSessionId)) requestQueue.removeAll { request in guard let update = request as? OSRequestUpdateSession, - update.createdSession == nil, + createdSession(forSession: update.localSessionId) == nil, !sessionsWithCreate.contains(update.localSessionId) else { return false @@ -329,6 +285,79 @@ private extension OSSessionOperationExecutor { } } +// MARK: - Session state + +private extension OSSessionOperationExecutor { + struct SessionState { + let localSessionId: String + /// What the create sent and got back. Every update of the session repeats it. + var created: OSCreatedSession? + /// An end leaves the queue once sent or dropped, but its session stays ended. + var ended = false + + init(localSessionId: String) { + self.localSessionId = localSessionId + } + + init?(entry: [String: Any]) { + guard let localSessionId = entry["localSessionId"] as? String else { + return nil + } + self.localSessionId = localSessionId + if let serverSessionId = entry["serverSessionId"] as? String, + let onesignalId = entry["onesignalId"] as? String, + let subscriptionId = entry["subscriptionId"] as? String { + created = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) + } + ended = entry["ended"] as? Bool ?? false + } + + var entry: [String: Any] { + var entry: [String: Any] = ["localSessionId": localSessionId, "ended": ended] + entry["serverSessionId"] = created?.serverSessionId + entry["onesignalId"] = created?.onesignalId + entry["subscriptionId"] = created?.subscriptionId + return entry + } + } + + func state(forSession sessionId: String) -> SessionState? { + sessions.first { $0.localSessionId == sessionId } + } + + func createdSession(forSession sessionId: String) -> OSCreatedSession? { + state(forSession: sessionId)?.created + } + + func updateState(forSession sessionId: String, _ change: (inout SessionState) -> Void) { + if let index = sessions.firstIndex(where: { $0.localSessionId == sessionId }) { + change(&sessions[index]) + } else { + var state = SessionState(localSessionId: sessionId) + change(&state) + sessions.append(state) + } + trimSessions() + cacheSessions() + } + + /// Drops the oldest sessions past the cap, but never one with Requests still queued, which need its state. + func trimSessions() { + var excess = sessions.count - Self.maxQueuedRequests + guard excess > 0 else { + return + } + let queuedSessions = Set(requestQueue.map(\.localSessionId)) + sessions.removeAll { state in + guard excess > 0, !queuedSessions.contains(state.localSessionId) else { + return false + } + excess -= 1 + return true + } + } +} + // MARK: - Sending private extension OSSessionOperationExecutor { @@ -344,6 +373,9 @@ private extension OSSessionOperationExecutor { var sessionsSeen = Set() var sentCreate = false for request in requestQueue { + if let update = request as? OSRequestUpdateSession { + update.createdSession = createdSession(forSession: update.localSessionId) + } guard sessionsSeen.insert(request.localSessionId).inserted, !request.sentToClient, !retryBackoff.isWaiting(request), @@ -421,18 +453,9 @@ private extension OSSessionOperationExecutor { } requestQueue.removeAll { $0 === request } let created = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) - recordCreated(created, forSession: create.localSessionId) - for case let update as OSRequestUpdateSession in requestQueue where update.localSessionId == create.localSessionId { - update.createdSession = created - } + updateState(forSession: create.localSessionId) { $0.created = created } // Updates wait out the post-create delay, so the backend has the session first. newRecordsState.add(serverSessionId) - OSSessionService.shared.setCreatedSession( - forSessionId: create.localSessionId, - serverSessionId: serverSessionId, - onesignalId: onesignalId, - subscriptionId: subscriptionId - ) cacheRequests() } @@ -475,7 +498,7 @@ private extension OSSessionOperationExecutor { case let create as OSRequestCreateSession: return [create.onesignalId, create.subscriptionId].contains { $0.map(newRecordsState.isInMissingRetryWindow) ?? false } case let update as OSRequestUpdateSession: - return update.createdSession.map { newRecordsState.isInMissingRetryWindow($0.serverSessionId) } ?? false + return createdSession(forSession: update.localSessionId).map { newRecordsState.isInMissingRetryWindow($0.serverSessionId) } ?? false default: return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index ae197e567..5947e527f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -71,6 +71,8 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String? = nil) { var value: [String: Any] = [OSSessionDeltaKey.startTime: record.startTime] value[OSSessionDeltaKey.directAttributionId] = directAttributionId + value[OSSessionDeltaKey.onesignalId] = record.onesignalId + value[OSSessionDeltaKey.subscriptionId] = record.subscriptionId enqueueSessionDelta(OS_CREATE_SESSION_DELTA, record: record, value: value) } @@ -78,7 +80,6 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: Date? = nil) { var value: [String: Any] = [OSSessionDeltaKey.activeDuration: record.activeDuration] value[OSSessionDeltaKey.endTime] = endTime?.timeIntervalSince1970 - value[OSSessionDeltaKey.serverSessionId] = record.serverSessionId enqueueSessionDelta(OS_UPDATE_SESSION_DELTA, record: record, value: value) } @@ -96,8 +97,6 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { } var value = value value[OSSessionDeltaKey.appId] = appId - value[OSSessionDeltaKey.onesignalId] = record.onesignalId - value[OSSessionDeltaKey.subscriptionId] = record.subscriptionId let delta = OSDelta( name: name, identityModelId: identityModelId, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift index 741914154..d05c8d4ca 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -38,7 +38,6 @@ enum OSSessionDeltaKey { static let endTime = "end_time" static let onesignalId = "onesignal_id" static let subscriptionId = "subscription_id" - static let serverSessionId = "server_session_id" } /// What a session's create sent and got back. Its updates must carry exactly these IDs. @@ -183,7 +182,7 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { var sentToClient = false let appId: String let localSessionId: String - /// Set once the session's create succeeds. Until then the update waits. + /// Set by the executor from its session state before each send, so it is not persisted. var createdSession: OSCreatedSession? /// Cumulative for the whole session, so a retried or reordered update cannot double count. var activeDuration: TimeInterval @@ -249,9 +248,6 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { func encode(with coder: NSCoder) { coder.encode(appId, forKey: "appId") coder.encode(localSessionId, forKey: "localSessionId") - coder.encode(createdSession?.serverSessionId, forKey: "serverSessionId") - coder.encode(createdSession?.onesignalId, forKey: "onesignalId") - coder.encode(createdSession?.subscriptionId, forKey: "subscriptionId") coder.encode(activeDuration, forKey: "activeDuration") coder.encode(endTime, forKey: "endTime") coder.encode(identityModel, forKey: "identityModel") @@ -271,11 +267,6 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { } self.appId = appId self.localSessionId = localSessionId - if let serverSessionId = coder.decodeObject(forKey: "serverSessionId") as? String, - let onesignalId = coder.decodeObject(forKey: "onesignalId") as? String, - let subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String { - self.createdSession = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) - } self.activeDuration = coder.decodeDouble(forKey: "activeDuration") self.endTime = coder.decodeObject(forKey: "endTime") as? Date self.identityModel = identityModel diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 6c9ac11ad..3eff24e24 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -105,23 +105,12 @@ final class OSSessionOperationExecutorTests: XCTestCase { return delta(OS_CREATE_SESSION_DELTA, session: session, value: value, identityModel: identityModel) } - /// A `serverSessionId` stands for a session record its create already filled, so it comes with the - /// create's IDs. - private func updateDelta( - session: String = "session-1", - duration: TimeInterval, - endTime: TimeInterval? = nil, - serverSessionId: String? = nil, - subscriptionId: String = testPushSubId - ) -> OSDelta { + private func updateDelta(session: String = "session-1", duration: TimeInterval, endTime: TimeInterval? = nil) -> OSDelta { var value: [String: Any] = [ OSSessionDeltaKey.appId: "test-app-id", - OSSessionDeltaKey.activeDuration: duration, - OSSessionDeltaKey.onesignalId: onesignalId, - OSSessionDeltaKey.subscriptionId: subscriptionId + OSSessionDeltaKey.activeDuration: duration ] value[OSSessionDeltaKey.endTime] = endTime - value[OSSessionDeltaKey.serverSessionId] = serverSessionId return delta(OS_UPDATE_SESSION_DELTA, session: session, value: value, identityModel: nil) } @@ -143,6 +132,12 @@ final class OSSessionOperationExecutorTests: XCTestCase { _ = executor.queuedRequests } + /// Sends the session's create and lets it succeed, leaving nothing queued. + private func createSession(_ executor: OSSessionOperationExecutor) { + flush(executor, createDelta()) + createSucceeds(executor) + } + private func fail(_ executor: OSSessionOperationExecutor, code: Int, headers: [String: String]? = nil) { client.failLast(code: code, headers: headers) _ = executor.queuedRequests @@ -216,21 +211,16 @@ final class OSSessionOperationExecutorTests: XCTestCase { } func testUpdatesCarryTheIdsTheCreateSent() throws { - OSSessionService.shared.startNewSession(userProvider: OneSignalUserManagerImpl.sharedInstance) - let sessionId = try XCTUnwrap(OSSessionService.shared.currentRecord?.sessionId) let executor = makeExecutor() - flush(executor, createDelta(session: sessionId), updateDelta(session: sessionId, duration: 10, subscriptionId: "other-subscription-id")) + flush(executor, createDelta(), updateDelta(duration: 10)) OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = "new-subscription-id" createSucceeds(executor) - flush(executor, updateDelta(session: sessionId, duration: 20, subscriptionId: "other-subscription-id")) + flush(executor, updateDelta(duration: 20)) let update = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) XCTAssertEqual(update.parameters?["onesignal_id"] as? String, onesignalId) XCTAssertEqual(update.parameters?["subscription_id"] as? String, testPushSubId) - let record = try XCTUnwrap(OSSessionService.shared.currentRecord) - XCTAssertEqual(record.serverSessionId, "server-1") - XCTAssertEqual(record.subscriptionId, testPushSubId) } func testNothingIsSentBeforePrivacyConsent() { @@ -246,8 +236,7 @@ final class OSSessionOperationExecutorTests: XCTestCase { usesSessionsApi: true, identityModelId: user.identityModel.modelId, onesignalId: nil, - subscriptionId: nil, - serverSessionId: nil + subscriptionId: nil )) manager.operationRepo.dispatchQueue.sync {} @@ -268,13 +257,14 @@ final class OSSessionOperationExecutorTests: XCTestCase { func testUpdateAfterASentEndIsDiscarded() { let executor = makeExecutor() - flush(executor, updateDelta(duration: 20, endTime: 1_700_000_100, serverSessionId: "server-1")) + createSession(executor) + flush(executor, updateDelta(duration: 20, endTime: 1_700_000_100)) client.succeedLast() - let queued = flush(executor, updateDelta(duration: 30, serverSessionId: "server-1")) + let queued = flush(executor, updateDelta(duration: 30)) XCTAssertTrue(queued.isEmpty) - XCTAssertEqual(client.requests.count, 1) + XCTAssertEqual(client.requests.count, 2) } func testEndReplacesQueuedUpdatesAndLaterUpdatesAreDiscarded() { @@ -290,8 +280,9 @@ final class OSSessionOperationExecutorTests: XCTestCase { func testUpdateInFlightCountsTowardTheHighestDuration() { let executor = makeExecutor() - flush(executor, updateDelta(duration: 30, serverSessionId: "server-1")) - XCTAssertEqual(client.requests.count, 1) + createSession(executor) + flush(executor, updateDelta(duration: 30)) + XCTAssertEqual(client.requests.count, 2) let queued = flush(executor, updateDelta(duration: 20)) @@ -375,11 +366,12 @@ final class OSSessionOperationExecutorTests: XCTestCase { func testReplacingAnUpdateKeepsItsBackoff() { let executor = makeExecutor() - flush(executor, updateDelta(duration: 10, serverSessionId: "server-1")) + createSession(executor) + flush(executor, updateDelta(duration: 10)) fail(executor, code: 500) flush(executor, updateDelta(duration: 20)) - XCTAssertEqual(client.requests.count, 1, "The replacement skipped the backoff") + XCTAssertEqual(client.requests.count, 2, "The replacement skipped the backoff") uptime += OSRequestRetryBackoff.baseDelaySeconds flush(executor) @@ -427,7 +419,8 @@ final class OSSessionOperationExecutorTests: XCTestCase { func testUpdateWithAnUnparsableSuccessBodyCompletes() { let executor = makeExecutor() - flush(executor, updateDelta(duration: 10, serverSessionId: "server-1")) + createSession(executor) + flush(executor, updateDelta(duration: 10)) fail(executor, code: 202) @@ -538,6 +531,25 @@ extension OSSessionOperationExecutorTests { XCTAssertEqual(update.path, "apps/test-app-id/sessions/server-1") } + func testUpdateAfterARestartUsesTheCreateFromBefore() throws { + createSession(makeExecutor()) + + flush(makeExecutor(), updateDelta(duration: 10)) + + let update = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(update.path, "apps/test-app-id/sessions/server-1") + } + + func testUpdateAfterASentEndIsDiscardedAfterARestart() { + let executor = makeExecutor() + createSession(executor) + flush(executor, updateDelta(duration: 20, endTime: 1_700_000_100)) + client.succeedLast() + _ = executor.queuedRequests + + XCTAssertTrue(flush(makeExecutor(), updateDelta(duration: 30)).isEmpty) + } + func testCurrentUsersRequestIsDroppedPastTheSessionAgeLimit() { user.identityModel.removeAliases([OS_ONESIGNAL_ID]) flush(makeExecutor(), createDelta()) @@ -595,8 +607,7 @@ extension OSSessionOperationExecutorTests { usesSessionsApi: usesSessionsApi, identityModelId: self.user.identityModel.modelId, onesignalId: nil, - subscriptionId: nil, - serverSessionId: nil + subscriptionId: nil ) } From 527919e571a493db738730774ee7eb0fe8797b68 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 19 Oct 2026 10:14:52 -0700 Subject: [PATCH 09/10] fix: [SDK-5379] age a session request from its delta Co-Authored-By: Claude Opus 5.5 --- .../OSSessionOperationExecutor.swift | 43 +++++++++++++------ .../OSSessionOperationExecutorTests.swift | 7 +++ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index 985d6b805..ae7c83d37 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -151,7 +151,7 @@ class OSSessionOperationExecutor: OSOperationExecutor { if !self.deltaQueue.isEmpty { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSSessionOperationExecutor processDeltaQueue with queue: \(self.deltaQueue)") for delta in self.deltaQueue { - if let request = self.makeRequest(from: delta) { + if let request = self.makeRequest(from: delta), !self.dropIfStale(request) { self.admit(request) } } @@ -193,8 +193,9 @@ private extension OSSessionOperationExecutor { return nil } let localSessionId = delta.property + let request: OSSessionRequest if delta.name == OS_CREATE_SESSION_DELTA { - return OSRequestCreateSession( + request = OSRequestCreateSession( appId: appId, localSessionId: localSessionId, startTime: Date(timeIntervalSince1970: value[OSSessionDeltaKey.startTime] as? TimeInterval ?? 0), @@ -204,15 +205,32 @@ private extension OSSessionOperationExecutor { onesignalId: value[OSSessionDeltaKey.onesignalId] as? String, subscriptionId: value[OSSessionDeltaKey.subscriptionId] as? String ) + } else { + request = OSRequestUpdateSession( + appId: appId, + localSessionId: localSessionId, + activeDuration: value[OSSessionDeltaKey.activeDuration] as? TimeInterval ?? 0, + endTime: (value[OSSessionDeltaKey.endTime] as? TimeInterval).map { Date(timeIntervalSince1970: $0) }, + identityModel: identityModel, + ownerExternalId: delta.externalId + ) + } + // Aged from when the session reported it, since the delta can outlive a kill. + request.timestamp = delta.timestamp + return request + } + + /// Checked before admitting, so a stale Request cannot evict a queued one. + func dropIfStale(_ request: OSSessionRequest) -> Bool { + guard let age = staleAge(of: request) else { + return false } - return OSRequestUpdateSession( - appId: appId, - localSessionId: localSessionId, - activeDuration: value[OSSessionDeltaKey.activeDuration] as? TimeInterval ?? 0, - endTime: (value[OSSessionDeltaKey.endTime] as? TimeInterval).map { Date(timeIntervalSince1970: $0) }, - identityModel: identityModel, - ownerExternalId: delta.externalId - ) + OSRequestAging.logDrop(of: "\(type(of: request))", owner: request.ownerExternalId, age: age, executor: "OSSessionOperationExecutor") + return true + } + + func staleAge(of request: OSSessionRequest) -> TimeInterval? { + OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: OSRequestAging.sessionRequestMaxAge, now: nowProvider(), currentExternalId: OSRequestAging.currentExternalId) } func admit(_ request: OSSessionRequest) { @@ -396,14 +414,11 @@ private extension OSSessionOperationExecutor { func removeUnsendableRequests() -> Bool { let countBefore = requestQueue.count let currentAppId = OneSignalIdentifiers.currentAppId - let now = nowProvider() - let currentExternalId = OSRequestAging.currentExternalId for request in requestQueue { if let currentAppId, request.appId != currentAppId { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(request), it belongs to another app") drop(request) - } else if let age = OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: OSRequestAging.sessionRequestMaxAge, now: now, currentExternalId: currentExternalId) { - OSRequestAging.logDrop(of: "\(type(of: request))", owner: request.ownerExternalId, age: age, executor: "OSSessionOperationExecutor") + } else if dropIfStale(request) { drop(request) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 3eff24e24..85b5126d7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -559,6 +559,13 @@ extension OSSessionOperationExecutorTests { XCTAssertTrue(flush(makeExecutor(now: { later })).isEmpty) } + func testRequestIsAgedFromItsDelta() { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let delta = createDelta() + + XCTAssertEqual(flush(makeExecutor(), delta).first?.timestamp, delta.timestamp) + } + func testRequestForAnotherAppIsDropped() { user.identityModel.removeAliases([OS_ONESIGNAL_ID]) let executor = makeExecutor() From a2595fcb2efd55327365a01df25640549988366f Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 19 Oct 2026 10:49:35 -0700 Subject: [PATCH 10/10] test: [SDK-5379] check a stale session delta cannot evict a queued request Co-Authored-By: Claude Opus 5.5 --- .../OneSignalOSCore/Source/OSDelta.swift | 4 +-- .../OSSessionOperationExecutorTests.swift | 33 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift index 6feb7eced..0884a197a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift @@ -46,10 +46,10 @@ open class OSDelta: NSObject, NSCoding { return "" } - public init(name: String, identityModelId: String, externalId: String?, model: OSModel, property: String, value: Any) { + public init(name: String, identityModelId: String, externalId: String?, model: OSModel, property: String, value: Any, timestamp: Date = Date()) { self.name = name self.deltaId = UUID().uuidString - self.timestamp = Date() + self.timestamp = timestamp self.identityModelId = identityModelId self.externalId = externalId self.model = model diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 85b5126d7..43f9d75d7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -96,13 +96,18 @@ final class OSSessionOperationExecutorTests: XCTestCase { ) } - private func createDelta(session: String = "session-1", directAttributionId: String? = nil, identityModel: OSIdentityModel? = nil) -> OSDelta { + private func createDelta( + session: String = "session-1", + directAttributionId: String? = nil, + identityModel: OSIdentityModel? = nil, + timestamp: Date = Date() + ) -> OSDelta { var value: [String: Any] = [ OSSessionDeltaKey.appId: "test-app-id", OSSessionDeltaKey.startTime: 1_700_000_000.0 ] value[OSSessionDeltaKey.directAttributionId] = directAttributionId - return delta(OS_CREATE_SESSION_DELTA, session: session, value: value, identityModel: identityModel) + return delta(OS_CREATE_SESSION_DELTA, session: session, value: value, identityModel: identityModel, timestamp: timestamp) } private func updateDelta(session: String = "session-1", duration: TimeInterval, endTime: TimeInterval? = nil) -> OSDelta { @@ -114,9 +119,17 @@ final class OSSessionOperationExecutorTests: XCTestCase { return delta(OS_UPDATE_SESSION_DELTA, session: session, value: value, identityModel: nil) } - private func delta(_ name: String, session: String, value: [String: Any], identityModel: OSIdentityModel?) -> OSDelta { + private func delta(_ name: String, session: String, value: [String: Any], identityModel: OSIdentityModel?, timestamp: Date = Date()) -> OSDelta { let identityModel = identityModel ?? user.identityModel - return OSDelta(name: name, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: identityModel, property: session, value: value) + return OSDelta( + name: name, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: identityModel, + property: session, + value: value, + timestamp: timestamp + ) } /// Enqueues the Deltas, runs one flush, and waits for it. @@ -566,6 +579,18 @@ extension OSSessionOperationExecutorTests { XCTAssertEqual(flush(makeExecutor(), delta).first?.timestamp, delta.timestamp) } + func testStaleDeltaDoesNotEvictAQueuedRequest() { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let executor = makeExecutor() + let queued = (0..