From 115bd13d2d1e3fbc377f7c3dbe6a635b919cb176 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 28 Sep 2026 08:54:55 -0700 Subject: [PATCH 1/3] fix: [SDK-5345] age out stale queued Requests Update Properties Requests are dropped after 90 days, Custom Events after 30 days, and any Request owned by a user other than the current one after 30 days. Each executor checks at uncache and at the start of every flush pass, before prepareForExecution, so a Request that ages past its limit goes even if it could send by then. Create User, Fetch User and the subscription Requests never age out. --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../Executors/OSCustomEventsExecutor.swift | 20 +- .../OSIdentityOperationExecutor.swift | 25 +- .../OSPropertyOperationExecutor.swift | 20 +- .../OneSignalUser/Source/OSRequestAging.swift | 75 +++++ .../Executors/ExecutorRequestAgingTests.swift | 294 ++++++++++++++++++ 6 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index e4d704f58..a641076e0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -53,11 +53,13 @@ 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; 16664C4C25DDB195003B8A14 /* NSTimeZoneOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */; }; 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */; }; + D9F1B3E5A7C2495FC1E6B8D3 /* ExecutorRequestAgingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */; }; 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */; }; 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */; }; 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */; }; + B7D2A9C4E6F1483DA2C5E9F1 /* OSRequestAging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C4E7F2B9D3468CA5E1F0B2 /* OSRequestAging.swift */; }; 37E6B2BB19D9CAF300D0C601 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C0151922C2E298F0079E076 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */; }; @@ -1719,6 +1721,7 @@ 91F60F7B1E80E49A00706E60 /* UncaughtExceptionHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UncaughtExceptionHandler.h; sourceTree = ""; }; 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UncaughtExceptionHandler.m; sourceTree = ""; }; 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAuth.swift; sourceTree = ""; }; + A1C4E7F2B9D3468CA5E1F0B2 /* OSRequestAging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAging.swift; sourceTree = ""; }; 9D1BD95D237663BF00A064F7 /* OSInfluenceDataDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSInfluenceDataDefines.h; sourceTree = ""; }; 9D1BD95E2379E7A900A064F7 /* OSOutcomeEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSOutcomeEvent.h; sourceTree = ""; }; 9D1BD95F2379E7C300A064F7 /* OSOutcomeEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSOutcomeEvent.m; sourceTree = ""; }; @@ -1961,6 +1964,7 @@ F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSAliasPair.swift; sourceTree = ""; }; FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExecutorAnonymousPurgeTests.swift; sourceTree = ""; }; + C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExecutorRequestAgingTests.swift; sourceTree = ""; }; FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoIdentityVerificationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2583,6 +2587,7 @@ 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, + C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */, ); path = Executors; sourceTree = ""; @@ -2965,6 +2970,7 @@ 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, + A1C4E7F2B9D3468CA5E1F0B2 /* OSRequestAging.swift */, ); path = Source; sourceTree = ""; @@ -4758,6 +4764,7 @@ AE95DD5B72F1D102F6D6062A /* XCTestCase+AsyncWork.swift in Sources */, 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */, 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */, + D9F1B3E5A7C2495FC1E6B8D3 /* ExecutorRequestAgingTests.swift in Sources */, AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */, 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */, 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, @@ -4982,6 +4989,7 @@ 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */, 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, + B7D2A9C4E6F1483DA2C5E9F1 /* OSRequestAging.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index dc948efb7..f8faecaa6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -50,13 +50,15 @@ class OSCustomEventsExecutor: OSOperationExecutor { private var requestQueue: [OSRequestCustomEvents] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let nowProvider: () -> Date // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSCustomEventsExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing, nowProvider: @escaping () -> Date = { Date() }) { self.newRecordsState = newRecordsState self.auth = auth + self.nowProvider = nowProvider // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheRequests() @@ -82,6 +84,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { private func uncacheRequests() { if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestCustomEvents] { + // Before the hook-up, so it does not restore an identity model for a Request that is going anyway. + _ = removeStaleRequests(from: &requestQueue) // Hook each uncached Request to the model in the store for (index, request) in requestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -271,6 +275,10 @@ class OSCustomEventsExecutor: OSOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. private func processRequestQueue(inBackground: Bool) { + if removeStaleRequests(from: &requestQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: requestQueue) + } + if requestQueue.isEmpty { return } @@ -280,6 +288,16 @@ class OSCustomEventsExecutor: OSOperationExecutor { } } + /// See `OSRequestAging`. Returns whether the queue changed, so the caller can rewrite its cache key. + private func removeStaleRequests(from queue: inout [OSRequestCustomEvents]) -> Bool { + return queue.removeStaleRequests( + typeLimit: OSRequestAging.customEventRequestMaxAge, + now: nowProvider(), + currentExternalId: OSRequestAging.currentExternalId, + executor: "OSCustomEventsExecutor" + ) + } + private func executeRequest(_ request: OSRequestCustomEvents, inBackground: Bool) { guard !request.sentToClient else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index 93a15057d..c2a3738c1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -36,13 +36,15 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private var removeRequestQueue: [OSRequestRemoveAlias] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let nowProvider: () -> Date // The Identity executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSIdentityOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing, nowProvider: @escaping () -> Date = { Date() }) { self.newRecordsState = newRecordsState self.auth = auth + self.nowProvider = nowProvider // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheAddAliasRequests() @@ -71,6 +73,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private func uncacheAddAliasRequests() { if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestAddAliases] { + // Before the hook-up, so it does not restore an identity model for a Request that is going anyway. + _ = removeStaleRequests(from: &addRequestQueue) // Hook each uncached Request to the model in the store for (index, request) in addRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -94,6 +98,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private func uncacheRemoveAliasRequests() { if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestRemoveAlias] { + // Before the hook-up, so it does not restore an identity model for a Request that is going anyway. + _ = removeStaleRequests(from: &removeRequestQueue) // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -196,6 +202,13 @@ class OSIdentityOperationExecutor: OSOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. private func processRequestQueue(inBackground: Bool) { + if removeStaleRequests(from: &addRequestQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: addRequestQueue) + } + if removeStaleRequests(from: &removeRequestQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: removeRequestQueue) + } + let requestQueue: [OneSignalRequest] = addRequestQueue + removeRequestQueue if requestQueue.isEmpty { @@ -216,6 +229,16 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } } + /// See `OSRequestAging`. Only the owner rule applies, since an alias change has no limit of its own. + private func removeStaleRequests(from queue: inout [T]) -> Bool { + return queue.removeStaleRequests( + typeLimit: nil, + now: nowProvider(), + currentExternalId: OSRequestAging.currentExternalId, + executor: "OSIdentityOperationExecutor" + ) + } + func executeAddAliasesRequest(_ request: OSRequestAddAliases, inBackground: Bool) { guard !request.sentToClient else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 2189459f5..7e6d3a83b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -71,13 +71,15 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private var updateRequestQueue: [OSRequestUpdateProperties] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let nowProvider: () -> Date // The property executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `updateRequestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSPropertyOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing, nowProvider: @escaping () -> Date = { Date() }) { self.newRecordsState = newRecordsState self.auth = auth + self.nowProvider = nowProvider // Read unfinished deltas and requests from cache, if any... // Note that we should only have deltas for the current user as old ones are flushed.. uncacheDeltas() @@ -102,6 +104,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private func uncacheUpdateRequests() { if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestUpdateProperties] { + // Before the hook-up, so it does not restore an identity model for a Request that is going anyway. + _ = removeStaleRequests(from: &updateRequestQueue) // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -249,6 +253,10 @@ class OSPropertyOperationExecutor: OSOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. private func processRequestQueue(inBackground: Bool) { + if removeStaleRequests(from: &updateRequestQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: updateRequestQueue) + } + if updateRequestQueue.isEmpty { return } @@ -258,6 +266,16 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } + /// See `OSRequestAging`. Returns whether the queue changed, so the caller can rewrite its cache key. + private func removeStaleRequests(from queue: inout [OSRequestUpdateProperties]) -> Bool { + return queue.removeStaleRequests( + typeLimit: OSRequestAging.propertyRequestMaxAge, + now: nowProvider(), + currentExternalId: OSRequestAging.currentExternalId, + executor: "OSPropertyOperationExecutor" + ) + } + func executeUpdatePropertiesRequest(_ request: OSRequestUpdateProperties, inBackground: Bool) { guard !request.sentToClient else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift new file mode 100644 index 000000000..cd2141104 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift @@ -0,0 +1,75 @@ +/* + 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 + +/// Age limits for queued user Requests. Each executor applies them at uncache and at the start of a +/// flush pass, before `prepareForExecution`, so a stale Request goes even if it could send by then. +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 + + /// The limit for `request`, nil when it never ages out. + static func maxAge(of request: OSUserRequest, typeLimit: TimeInterval?, currentExternalId: String?) -> TimeInterval? { + var limit = typeLimit + if let owner = request.ownerExternalId, owner != currentExternalId { + limit = min(limit ?? .infinity, nonCurrentUserRequestMaxAge) + } + return limit + } + + /// A timestamp ahead of the clock reads as age zero, so a clock set back does not empty the queues. + static func age(of request: OneSignalRequest, now: Date) -> TimeInterval { + return max(0, now.timeIntervalSince(request.timestamp)) + } + + /// The `external_id` of the user manager's current user, nil while there is none. + static var currentExternalId: String? { + return OneSignalUserManagerImpl.sharedInstance._user?.identityModel.externalId + } +} + +extension Array where Element: OSUserRequest { + /// Removes every Request past its age limit. Returns whether any went, so the caller can rewrite its cache key. + mutating func removeStaleRequests(typeLimit: TimeInterval?, now: Date, currentExternalId: String?, executor: String) -> Bool { + let countBefore = count + removeAll { request in + guard let limit = OSRequestAging.maxAge(of: request, typeLimit: typeLimit, currentExternalId: currentExternalId) else { + return false + } + let age = OSRequestAging.age(of: request, now: now) + guard age > limit else { + return false + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "\(executor) dropped \(type(of: request)) owned by \(request.ownerExternalId ?? "nobody"), \(Int(age / 86_400)) days old") + return true + } + return count != countBefore + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift new file mode 100644 index 000000000..06bcf59d9 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift @@ -0,0 +1,294 @@ +/* + 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 OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/** + What each executor drops from its queues on age alone. See `OSRequestAging` for the limits. + + A Request is aged from the `timestamp` its cache entry keeps, so these tests write Requests with old + timestamps straight into the cache and read back what the executor kept. User A is the current user and + user B is not. Both hold a token, so nothing here is dropped for being unsendable. + */ +final class ExecutorRequestAgingTests: XCTestCase { + private let day: TimeInterval = 86_400 + private let propertiesKey = OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY + private let addAliasesKey = OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY + private let removeAliasKey = OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY + private let customEventsKey = OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY + + private var client = MockOneSignalClient() + private var newRecordsState = MockNewRecordsState() + private var current = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + private var other = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + /// The clock the executors under test read. + private var now = Date() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + newRecordsState.holdWhilePresent = true + now = Date() + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + current = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID).identityModel + current.jwtBearerToken = "token-a" + other = addUserToRepo(externalId: userB_EUID, onesignalId: userB_OSID, token: "token-b") + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Setup helpers + + private func addUserToRepo(externalId: String, onesignalId: String, token: String) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: onesignalId, OS_EXTERNAL_ID: externalId], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) + return model + } + + private var auth: OSRequestAuthorizing { + return OneSignalUserManagerImpl.sharedInstance.requestAuth + } + + private func propertyExecutor() -> OSPropertyOperationExecutor { + return OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth, nowProvider: { self.now }) + } + + private func identityExecutor() -> OSIdentityOperationExecutor { + return OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: auth, nowProvider: { self.now }) + } + + private func customEventsExecutor() -> OSCustomEventsExecutor { + return OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: auth, nowProvider: { self.now }) + } + + /// A negative `daysOld` puts the timestamp ahead of the clock. + private func aged(_ request: T, daysOld: Double) -> T { + request.timestamp = now.addingTimeInterval(-daysOld * day) + return request + } + + private func propertyUpdate(for identityModel: OSIdentityModel, daysOld: Double) -> OSRequestUpdateProperties { + let request = OSRequestUpdateProperties(params: ["properties": ["language": "en"]], identityModel: identityModel, ownerExternalId: identityModel.externalId) + return aged(request, daysOld: daysOld) + } + + private func addAliases(for identityModel: OSIdentityModel, daysOld: Double) -> OSRequestAddAliases { + let request = OSRequestAddAliases(aliases: ["test_alias_label": "test-alias-id"], identityModel: identityModel, ownerExternalId: identityModel.externalId) + return aged(request, daysOld: daysOld) + } + + private func removeAlias(for identityModel: OSIdentityModel, daysOld: Double) -> OSRequestRemoveAlias { + let request = OSRequestRemoveAlias(labelToRemove: "test_alias_label", identityModel: identityModel, ownerExternalId: identityModel.externalId) + return aged(request, daysOld: daysOld) + } + + private func customEvents(for identityModel: OSIdentityModel, daysOld: Double) -> OSRequestCustomEvents { + let event: [String: Any] = [ + "name": "test_event", + "onesignal_id": identityModel.onesignalId ?? "", + "timestamp": "2026-01-01T00:00:00Z", + "payload": ["test_property": "test-value"] + ] + let request = OSRequestCustomEvents(events: [event], identityModel: identityModel, ownerExternalId: identityModel.externalId) + return aged(request, daysOld: daysOld) + } + + private func cache(_ requests: [T], under key: String) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: key, withValue: requests) + } + + // MARK: - Assertion helpers + + private func cachedOwners(_ key: String, of type: T.Type) -> [String?] { + let requests = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: key, defaultValue: []) as? [T] ?? [] + return requests.map { $0.ownerExternalId } + } + + // MARK: - Property updates + + func testAPropertyUpdate91DaysOldIsDroppedAtUncache() { + cache([propertyUpdate(for: current, daysOld: 91)], under: propertiesKey) + + _ = propertyExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), []) + } + + func testAPropertyUpdate89DaysOldIsKeptAtUncache() { + cache([propertyUpdate(for: current, daysOld: 89)], under: propertiesKey) + + _ = propertyExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) + } + + /// The check runs before `prepareForExecution`, so the Request goes on the flush that finds it stale + /// even though the hold that stopped the first flush has lifted by then. + func testAPropertyUpdateThatCrosses90DaysBetweenFlushesIsDroppedAtTheSecondFlush() { + newRecordsState.add(userA_OSID) + cache([propertyUpdate(for: current, daysOld: 89)], under: propertiesKey) + let executor = propertyExecutor() + + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(client.executedRequests.count, 0) + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) + + newRecordsState.holdWhilePresent = false + now = now.addingTimeInterval(2 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The stale Update Properties was not dropped from the cache") { + self.cachedOwners(self.propertiesKey, of: OSRequestUpdateProperties.self).isEmpty + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + + /// The premise of the test above: once the hold lifts, a Request still under its limit is sent. + func testAPropertyUpdateStillUnderItsLimitIsSentOnceTheHoldLifts() { + newRecordsState.add(userA_OSID) + cache([propertyUpdate(for: current, daysOld: 89)], under: propertiesKey) + let executor = propertyExecutor() + + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(client.executedRequests.count, 0) + + newRecordsState.holdWhilePresent = false + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The Update Properties was not sent") { + self.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1) + } + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1)) + } + + // MARK: - Non-current users + + func testRequestsOwnedByANonCurrentUserAreDroppedAt31Days() { + cache([propertyUpdate(for: other, daysOld: 31)], under: propertiesKey) + cache([addAliases(for: other, daysOld: 31)], under: addAliasesKey) + cache([removeAlias(for: other, daysOld: 31)], under: removeAliasKey) + cache([customEvents(for: other, daysOld: 31)], under: customEventsKey) + + _ = propertyExecutor() + _ = identityExecutor() + _ = customEventsExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), []) + XCTAssertEqual(cachedOwners(addAliasesKey, of: OSRequestAddAliases.self), []) + XCTAssertEqual(cachedOwners(removeAliasKey, of: OSRequestRemoveAlias.self), []) + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), []) + } + + func testRequestsOwnedByANonCurrentUserAreKeptAt29Days() { + cache([propertyUpdate(for: other, daysOld: 29)], under: propertiesKey) + cache([addAliases(for: other, daysOld: 29)], under: addAliasesKey) + cache([removeAlias(for: other, daysOld: 29)], under: removeAliasKey) + cache([customEvents(for: other, daysOld: 29)], under: customEventsKey) + + _ = propertyExecutor() + _ = identityExecutor() + _ = customEventsExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userB_EUID]) + XCTAssertEqual(cachedOwners(addAliasesKey, of: OSRequestAddAliases.self), [userB_EUID]) + XCTAssertEqual(cachedOwners(removeAliasKey, of: OSRequestRemoveAlias.self), [userB_EUID]) + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), [userB_EUID]) + } + + func testAPropertyUpdateOwnedByTheCurrentUserIsKeptAt31Days() { + cache([propertyUpdate(for: current, daysOld: 31)], under: propertiesKey) + + _ = propertyExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) + } + + // MARK: - Custom events + + func testCustomEventsOwnedByTheCurrentUserAreDroppedAt31Days() { + cache([customEvents(for: current, daysOld: 31)], under: customEventsKey) + + _ = customEventsExecutor() + + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), []) + } + + // MARK: - Requests with no age limit + + func testACreateUser91DaysOldIsKeptAtUncache() throws { + let user = try XCTUnwrap(OneSignalUserManagerImpl.sharedInstance._user) + // Held so the User executor cannot send it. The assertion is that uncache kept it. + newRecordsState.add(testPushSubId) + let request = OSRequestCreateUser(identityModel: current, propertiesModel: user.propertiesModel, pushSubscriptionModel: user.pushSubscriptionModel, originalPushToken: nil) + cache([aged(request, daysOld: 91)], under: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY) + + _ = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, auth: auth) + allowAsyncWorkToRun() + + XCTAssertEqual(cachedOwners(OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, of: OSRequestCreateUser.self), [userA_EUID]) + XCTAssertEqual(client.executedRequests.count, 0) + } + + func testACreateSubscription91DaysOldIsKeptAtUncache() { + let subscription = OSSubscriptionModel(type: .email, address: "a@example.com", subscriptionId: "test-email-subscription-id", reachable: true, isDisabled: false, changeNotifier: OSEventProducer()) + let request = OSRequestCreateSubscription(subscriptionModel: subscription, identityModel: current, ownerExternalId: userA_EUID) + cache([aged(request, daysOld: 91)], under: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY) + + _ = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + XCTAssertEqual(cachedOwners(OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestCreateSubscription.self), [userA_EUID]) + } + + // MARK: - Clock + + /// Owned by the other user, so both limits would apply if it had any age. + func testARequestWithATimestampAheadOfTheClockIsKept() { + cache([propertyUpdate(for: other, daysOld: -365)], under: propertiesKey) + + _ = propertyExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userB_EUID]) + } +} From 026aec3ca56b612bd830d26b32797d6979136632 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 28 Sep 2026 14:04:50 -0700 Subject: [PATCH 2/3] chore: [SDK-5345] age custom event Deltas and cover the flush paths A custom event stays a Delta until its user has an onesignal_id, and the Request built later started at age zero, so events parked for a token skipped both limits. The custom events executor now ages its Deltas by their timestamp at uncache and on every pass, and a Request built from a Delta keeps the Delta's timestamp. Tests cover the identity and custom events flush paths, a user switch between flushes, and the Delta case. The age clamp is gone since a negative age never passes a positive limit, and the drop comments say dropped rather than goes. --- .../Executors/OSCustomEventsExecutor.swift | 32 ++++- .../OSIdentityOperationExecutor.swift | 4 +- .../OSPropertyOperationExecutor.swift | 4 +- .../OneSignalUser/Source/OSRequestAging.swift | 51 +++++-- .../Executors/ExecutorRequestAgingTests.swift | 134 +++++++++++++++++- 5 files changed, 198 insertions(+), 27 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index f8faecaa6..57162d51d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -66,6 +66,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { private func uncacheDeltas() { if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { + _ = removeStaleDeltas(from: &deltaQueue) for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -84,7 +85,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { private func uncacheRequests() { if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestCustomEvents] { - // Before the hook-up, so it does not restore an identity model for a Request that is going anyway. + // Before the hook-up, so it does not restore an identity model for a Request that is dropped anyway. _ = removeStaleRequests(from: &requestQueue) // Hook each uncached Request to the model in the store for (index, request) in requestQueue.enumerated().reversed() { @@ -144,6 +145,9 @@ class OSCustomEventsExecutor: OSOperationExecutor { /// This method will be used in an upcoming release that combine multiple events. func processDeltaQueueWithBatching(inBackground: Bool) { self.dispatchQueue.async { + if self.removeStaleDeltas(from: &self.deltaQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } if self.deltaQueue.isEmpty { // Delta queue is empty but there may be pending requests self.processRequestQueue(inBackground: inBackground) @@ -152,7 +156,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor processDeltaQueue with queue: \(self.deltaQueue)") // Holds mapping of identity model ID to the events for it, with the owner the Deltas stamped - var combinedEvents: [String: (events: [[String: Any]], ownerExternalId: String?)] = [:] + var combinedEvents: [String: (events: [[String: Any]], ownerExternalId: String?, timestamp: Date)] = [:] // 1. Combine the events for every distinct user for (index, delta) in self.deltaQueue.enumerated().reversed() { @@ -178,7 +182,10 @@ class OSCustomEventsExecutor: OSOperationExecutor { EventConstants.payload: self.addSdkMetadata(properties: properties) ] - combinedEvents[identityModel.modelId, default: ([], delta.externalId)].events.append(event) + var combined = combinedEvents[identityModel.modelId, default: ([], delta.externalId, delta.timestamp)] + combined.events.append(event) + combined.timestamp = min(combined.timestamp, delta.timestamp) + combinedEvents[identityModel.modelId] = combined self.deltaQueue.remove(at: index) } @@ -194,6 +201,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { identityModel: identityModel, ownerExternalId: combined.ownerExternalId ) + // Aged from the oldest event, not from when its user got an onesignal_id. + request.timestamp = combined.timestamp self.requestQueue.append(request) } @@ -207,6 +216,9 @@ class OSCustomEventsExecutor: OSOperationExecutor { func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { + if self.removeStaleDeltas(from: &self.deltaQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } if self.deltaQueue.isEmpty { // Delta queue is empty but there may be pending requests self.processRequestQueue(inBackground: inBackground) @@ -244,6 +256,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { identityModel: identityModel, ownerExternalId: delta.externalId ) + // Aged from the event, not from when its user got an onesignal_id. + request.timestamp = delta.timestamp self.requestQueue.append(request) } @@ -288,7 +302,17 @@ class OSCustomEventsExecutor: OSOperationExecutor { } } - /// See `OSRequestAging`. Returns whether the queue changed, so the caller can rewrite its cache key. + /// See `OSRequestAging`. Deltas wait here until their user has an `onesignal_id`, so they are aged too. + private func removeStaleDeltas(from queue: inout [OSDelta]) -> Bool { + return queue.removeStaleDeltas( + typeLimit: OSRequestAging.customEventRequestMaxAge, + now: nowProvider(), + currentExternalId: OSRequestAging.currentExternalId, + executor: "OSCustomEventsExecutor" + ) + } + + /// See `OSRequestAging`. Returns whether the queue changed, so the caller can rewrite its cache entry. private func removeStaleRequests(from queue: inout [OSRequestCustomEvents]) -> Bool { return queue.removeStaleRequests( typeLimit: OSRequestAging.customEventRequestMaxAge, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index c2a3738c1..ecf130098 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -73,7 +73,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private func uncacheAddAliasRequests() { if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestAddAliases] { - // Before the hook-up, so it does not restore an identity model for a Request that is going anyway. + // Before the hook-up, so it does not restore an identity model for a Request that is dropped anyway. _ = removeStaleRequests(from: &addRequestQueue) // Hook each uncached Request to the model in the store for (index, request) in addRequestQueue.enumerated().reversed() { @@ -98,7 +98,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private func uncacheRemoveAliasRequests() { if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestRemoveAlias] { - // Before the hook-up, so it does not restore an identity model for a Request that is going anyway. + // Before the hook-up, so it does not restore an identity model for a Request that is dropped anyway. _ = removeStaleRequests(from: &removeRequestQueue) // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 7e6d3a83b..41b773f17 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -104,7 +104,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private func uncacheUpdateRequests() { if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestUpdateProperties] { - // Before the hook-up, so it does not restore an identity model for a Request that is going anyway. + // Before the hook-up, so it does not restore an identity model for a Request that is dropped anyway. _ = removeStaleRequests(from: &updateRequestQueue) // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { @@ -266,7 +266,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } - /// See `OSRequestAging`. Returns whether the queue changed, so the caller can rewrite its cache key. + /// See `OSRequestAging`. Returns whether the queue changed, so the caller can rewrite its cache entry. private func removeStaleRequests(from queue: inout [OSRequestUpdateProperties]) -> Bool { return queue.removeStaleRequests( typeLimit: OSRequestAging.propertyRequestMaxAge, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift index cd2141104..835acd0a0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift @@ -27,47 +27,68 @@ import Foundation import OneSignalCore +import OneSignalOSCore /// Age limits for queued user Requests. Each executor applies them at uncache and at the start of a -/// flush pass, before `prepareForExecution`, so a stale Request goes even if it could send by then. +/// flush pass, before `prepareForExecution`, so a stale Request is dropped even if it could send by then. 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 - /// The limit for `request`, nil when it never ages out. - static func maxAge(of request: OSUserRequest, typeLimit: TimeInterval?, currentExternalId: String?) -> TimeInterval? { + /// The `external_id` of the current user, nil while there is none. Reads `_user`, since `user` calls + /// `start()` and would deadlock at uncache. + static var currentExternalId: String? { + return OneSignalUserManagerImpl.sharedInstance._user?.identityModel.externalId + } + + /// The limit for work owned by `owner`, nil when it never ages out. + static func maxAge(owner: String?, typeLimit: TimeInterval?, currentExternalId: String?) -> TimeInterval? { var limit = typeLimit - if let owner = request.ownerExternalId, owner != currentExternalId { + if let owner = owner, owner != currentExternalId { limit = min(limit ?? .infinity, nonCurrentUserRequestMaxAge) } return limit } - /// A timestamp ahead of the clock reads as age zero, so a clock set back does not empty the queues. - static func age(of request: OneSignalRequest, now: Date) -> TimeInterval { - return max(0, now.timeIntervalSince(request.timestamp)) + /// The age at which work with `timestamp` is dropped, nil to keep it. A timestamp ahead of the clock has no age. + static func staleAge(timestamp: Date, owner: String?, typeLimit: TimeInterval?, now: Date, currentExternalId: String?) -> TimeInterval? { + guard let limit = maxAge(owner: owner, typeLimit: typeLimit, currentExternalId: currentExternalId) else { + return nil + } + let age = now.timeIntervalSince(timestamp) + return age > limit ? age : nil } - /// The `external_id` of the user manager's current user, nil while there is none. - static var currentExternalId: String? { - return OneSignalUserManagerImpl.sharedInstance._user?.identityModel.externalId + static func logDrop(of item: String, owner: String?, age: TimeInterval, executor: String) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "\(executor) dropped \(item) owned by \(owner ?? "nil"), \(Int(age / 86_400)) days old") } } extension Array where Element: OSUserRequest { - /// Removes every Request past its age limit. Returns whether any went, so the caller can rewrite its cache key. + /// Removes every Request past its age limit. Returns whether any were dropped, so the caller can rewrite its cache entry. mutating func removeStaleRequests(typeLimit: TimeInterval?, now: Date, currentExternalId: String?, executor: String) -> Bool { let countBefore = count removeAll { request in - guard let limit = OSRequestAging.maxAge(of: request, typeLimit: typeLimit, currentExternalId: currentExternalId) else { + guard let age = OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: typeLimit, now: now, currentExternalId: currentExternalId) else { return false } - let age = OSRequestAging.age(of: request, now: now) - guard age > limit else { + OSRequestAging.logDrop(of: "\(type(of: request))", owner: request.ownerExternalId, age: age, executor: executor) + return true + } + return count != countBefore + } +} + +extension Array where Element == OSDelta { + /// The same for Deltas, which the custom events executor holds until their user has an `onesignal_id`. + mutating func removeStaleDeltas(typeLimit: TimeInterval?, now: Date, currentExternalId: String?, executor: String) -> Bool { + let countBefore = count + removeAll { delta in + guard let age = OSRequestAging.staleAge(timestamp: delta.timestamp, owner: delta.externalId, typeLimit: typeLimit, now: now, currentExternalId: currentExternalId) else { return false } - OneSignalLog.onesignalLog(.LL_DEBUG, message: "\(executor) dropped \(type(of: request)) owned by \(request.ownerExternalId ?? "nobody"), \(Int(age / 86_400)) days old") + OSRequestAging.logDrop(of: "\(delta.name) Delta", owner: delta.externalId, age: age, executor: executor) return true } return count != countBefore diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift index 06bcf59d9..bc8295e45 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift @@ -78,8 +78,12 @@ final class ExecutorRequestAgingTests: XCTestCase { // MARK: - Setup helpers - private func addUserToRepo(externalId: String, onesignalId: String, token: String) -> OSIdentityModel { - let model = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: onesignalId, OS_EXTERNAL_ID: externalId], changeNotifier: OSEventProducer()) + private func addUserToRepo(externalId: String, onesignalId: String?, token: String) -> OSIdentityModel { + var aliases = [OS_EXTERNAL_ID: externalId] + if let onesignalId = onesignalId { + aliases[OS_ONESIGNAL_ID] = onesignalId + } + let model = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) model.jwtBearerToken = token OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) return model @@ -137,6 +141,17 @@ final class ExecutorRequestAgingTests: XCTestCase { OneSignalUserDefaults.initShared().saveCodeableData(forKey: key, withValue: requests) } + private func customEventDelta(for identityModel: OSIdentityModel) -> OSDelta { + return OSDelta( + name: OS_CUSTOM_EVENT_DELTA, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: OSModel(changeNotifier: OSEventProducer()), + property: "test_event", + value: ["test_property": "test-value"] + ) + } + // MARK: - Assertion helpers private func cachedOwners(_ key: String, of type: T.Type) -> [String?] { @@ -144,6 +159,11 @@ final class ExecutorRequestAgingTests: XCTestCase { return requests.map { $0.ownerExternalId } } + private func cachedCustomEventDeltaCount() -> Int { + let deltas = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] ?? [] + return deltas.count + } + // MARK: - Property updates func testAPropertyUpdate91DaysOldIsDroppedAtUncache() { @@ -162,8 +182,8 @@ final class ExecutorRequestAgingTests: XCTestCase { XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) } - /// The check runs before `prepareForExecution`, so the Request goes on the flush that finds it stale - /// even though the hold that stopped the first flush has lifted by then. + /// The check runs before `prepareForExecution`, so the Request is dropped on the flush that finds it + /// stale even though the hold that stopped the first flush has lifted by then. func testAPropertyUpdateThatCrosses90DaysBetweenFlushesIsDroppedAtTheSecondFlush() { newRecordsState.add(userA_OSID) cache([propertyUpdate(for: current, daysOld: 89)], under: propertiesKey) @@ -203,6 +223,25 @@ final class ExecutorRequestAgingTests: XCTestCase { XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1)) } + /// The rule judges the owner at each flush, so a switch makes the outgoing user's older Requests non-current. + func testARequestOwnedByAUserWhoIsNoLongerCurrentIsDroppedAtTheNextFlush() { + newRecordsState.add(userA_OSID) + cache([propertyUpdate(for: current, daysOld: 45)], under: propertiesKey) + let executor = propertyExecutor() + + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) + + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The outgoing user's Update Properties was not dropped") { + self.cachedOwners(self.propertiesKey, of: OSRequestUpdateProperties.self).isEmpty + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + // MARK: - Non-current users func testRequestsOwnedByANonCurrentUserAreDroppedAt31Days() { @@ -245,6 +284,25 @@ final class ExecutorRequestAgingTests: XCTestCase { XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) } + // MARK: - Aliases + + /// Both queues are aged at flush, and each rewrites its own cache entry. + func testAnIdentityFlushDropsStaleAliasRequestsFromBothQueues() { + newRecordsState.add(userA_OSID) + cache([addAliases(for: other, daysOld: 29)], under: addAliasesKey) + cache([removeAlias(for: other, daysOld: 29), removeAlias(for: current, daysOld: 29)], under: removeAliasKey) + let executor = identityExecutor() + + now = now.addingTimeInterval(2 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The stale alias Requests were not dropped from both cache entries") { + self.cachedOwners(self.addAliasesKey, of: OSRequestAddAliases.self).isEmpty + && self.cachedOwners(self.removeAliasKey, of: OSRequestRemoveAlias.self) == [userA_EUID] + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + // MARK: - Custom events func testCustomEventsOwnedByTheCurrentUserAreDroppedAt31Days() { @@ -255,6 +313,74 @@ final class ExecutorRequestAgingTests: XCTestCase { XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), []) } + func testACustomEventsFlushKeepsAt29DaysAndDropsAt31Days() { + newRecordsState.add(userA_OSID) + cache([customEvents(for: current, daysOld: 29)], under: customEventsKey) + let executor = customEventsExecutor() + + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), [userA_EUID]) + + now = now.addingTimeInterval(2 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The stale Custom Events was not dropped from the cache") { + self.cachedOwners(self.customEventsKey, of: OSRequestCustomEvents.self).isEmpty + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + + /// A custom event stays a Delta until its user has an `onesignal_id`, so it is aged where it waits. + func testACustomEventDeltaWaitingForAnOnesignalIdIsDroppedAt31Days() { + let pending = addUserToRepo(externalId: "test_user_c_external_id", onesignalId: nil, token: "token-c") + let executor = customEventsExecutor() + executor.enqueueDelta(customEventDelta(for: pending)) + + now = now.addingTimeInterval(29 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The waiting Delta was not cached") { + self.cachedCustomEventDeltaCount() == 1 + } + + now = now.addingTimeInterval(2 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The stale Delta was not dropped from the cache") { + self.cachedCustomEventDeltaCount() == 0 + } + + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), []) + XCTAssertEqual(client.executedRequests.count, 0) + } + + /// The Request built once the id arrives keeps the Delta's timestamp, so its 30 days count from the event. + func testACustomEventRequestIsAgedFromItsDelta() { + let pendingOSID = "test_user_c_onesignal_id" + let pending = addUserToRepo(externalId: "test_user_c_external_id", onesignalId: nil, token: "token-c") + newRecordsState.add(pendingOSID) + let executor = customEventsExecutor() + executor.enqueueDelta(customEventDelta(for: pending)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The waiting Delta was not cached") { + self.cachedCustomEventDeltaCount() == 1 + } + + now = now.addingTimeInterval(20 * day) + pending.addAliases([OS_ONESIGNAL_ID: pendingOSID]) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The Delta did not become a Request once its user had an onesignal_id") { + self.cachedOwners(self.customEventsKey, of: OSRequestCustomEvents.self) == ["test_user_c_external_id"] + } + + now = now.addingTimeInterval(11 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The Request was not aged from its Delta") { + self.cachedOwners(self.customEventsKey, of: OSRequestCustomEvents.self).isEmpty + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + // MARK: - Requests with no age limit func testACreateUser91DaysOldIsKeptAtUncache() throws { From f0e87a8b66bb4ed55ea6bca19cf091eec96b5073 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 7 Oct 2026 10:47:25 -0700 Subject: [PATCH 3/3] chore: [SDK-5345] move the beta cache cleanup into the Jwt extension OneSignalUserManagerImpl.swift reached 1003 lines once #1758 landed on 5.8-main, over SwiftLint's 1000-line file_length error, so every PR against the branch failed the lint job. The helper moves to OneSignalUserManagerImpl+Jwt.swift with the rest of the Identity Verification surface and start() still calls it. No behavior change. --- .../Source/OneSignalUserManagerImpl+Jwt.swift | 17 +++++++++++++++++ .../Source/OneSignalUserManagerImpl.swift | 17 ----------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 7f6c55729..078e2835f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -184,4 +184,21 @@ extension OneSignalUserManagerImpl { } return requestAuth.authorization(onesignalId: identityModel.onesignalId, externalId: identityModel.externalId) } + + /// The 5.3.0-beta builds parked Requests awaiting a JWT under these keys and never trimmed them, so an + /// upgrading device can carry a large blob nothing reads anymore. + func removeIdentityVerificationBetaCaches() { + let defaults = OneSignalUserDefaults.initShared() + let keys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in keys where defaults.keyExists(key) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager removing the Identity Verification beta cache \(key)") + defaults.removeValue(forKey: key) + } + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 0247e8dc3..1e5dc6a11 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -238,23 +238,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } - /// The 5.3.0-beta builds parked Requests awaiting a JWT under these keys and never trimmed them, so an - /// upgrading device can carry a large blob nothing reads anymore. - private func removeIdentityVerificationBetaCaches() { - let defaults = OneSignalUserDefaults.initShared() - let keys = [ - OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY - ] - for key in keys where defaults.keyExists(key) { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager removing the Identity Verification beta cache \(key)") - defaults.removeValue(forKey: key) - } - } - @objc public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else {