Skip to content

Commit 6a37a52

Browse files
nan-licursoragent
andauthored
refactor: [SDK-4911] rebuild OperationRepo unmatched delta queue on flush (#1693)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e5e5802 commit 6a37a52

3 files changed

Lines changed: 188 additions & 4 deletions

File tree

iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */; };
8383
3C3D34E92E95EAA5006A2924 /* LiveActivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */; };
8484
3C3D8D782E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */; };
85+
3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */; };
8586
3C4319092F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C4319082F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift */; };
8687
3C44673E296D099D0039A49E /* OneSignalMobileProvision.m in Sources */ = {isa = PBXBuildFile; fileRef = 912411FD1E73342200E41FD7 /* OneSignalMobileProvision.m */; };
8788
3C44673F296D09CC0039A49E /* OneSignalMobileProvision.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411FC1E73342200E41FD7 /* OneSignalMobileProvision.h */; settings = {ATTRIBUTES = (Public, ); }; };
@@ -1352,6 +1353,7 @@
13521353
3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyTriggerTrackingTests.swift; sourceTree = "<group>"; };
13531354
3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConstants.swift; sourceTree = "<group>"; };
13541355
3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivityViewExtensions.swift; sourceTree = "<group>"; };
1356+
3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRepoFlushTests.swift; sourceTree = "<group>"; };
13551357
3C4319082F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEndOutcomesRequestTests.swift; sourceTree = "<group>"; };
13561358
3C448B9B2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSBackgroundTaskHandlerImpl.h; sourceTree = "<group>"; };
13571359
3C448B9C2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSBackgroundTaskHandlerImpl.m; sourceTree = "<group>"; };
@@ -2528,6 +2530,7 @@
25282530
isa = PBXGroup;
25292531
children = (
25302532
5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */,
2533+
3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */,
25312534
3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */,
25322535
3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */,
25332536
3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */,
@@ -4550,6 +4553,7 @@
45504553
5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */,
45514554
3C23A21F2FCE0AA1001D32E3 /* OSResilientStorageTests.swift in Sources */,
45524555
3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */,
4556+
3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */,
45534557
3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */,
45544558
);
45554559
runOnlyForDeploymentPostprocessing = 0;

iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,16 +151,16 @@ public class OSOperationRepo: NSObject {
151151
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo flushDeltaQueue in background: \(inBackground) with queue: \(self.deltaQueue)")
152152
}
153153

154-
var index = 0
154+
var unmatched: [OSDelta] = []
155155
for delta in self.deltaQueue {
156156
if let executor = self.deltasToExecutorMap[delta.name] {
157157
executor.enqueueDelta(delta)
158-
self.deltaQueue.remove(at: index)
159158
} else {
160-
// keep in queue if no executor matches, we may not have the executor available yet
161-
index += 1
159+
// Keep if no executor matches yet (module may not have started).
160+
unmatched.append(delta)
162161
}
163162
}
163+
self.deltaQueue = unmatched
164164

165165
// Persist the deltas (including removed deltas) to storage after they are divvy'd up to executors.
166166
OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue)
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/*
2+
Modified MIT License
3+
4+
Copyright 2026 OneSignal
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
1. The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
2. All copies of substantial portions of the Software may only be used in connection
17+
with services provided by OneSignal.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
*/
27+
28+
import Foundation
29+
import XCTest
30+
import OneSignalCore
31+
@testable import OneSignalOSCore
32+
33+
/// Covers `flushDeltaQueue` routing: matched deltas go to executors and leave the repo
34+
/// queue; unmatched names stay queued (and in order).
35+
final class OSOperationRepoFlushTests: XCTestCase {
36+
37+
private let knownDelta = "test_known_delta"
38+
private let unknownDelta = "test_unknown_delta"
39+
40+
override func setUp() {
41+
super.setUp()
42+
OneSignalIdentifiers.currentAppId = "test-app-id"
43+
resetOperationRepo()
44+
// Pause so the poller (started by addExecutor/start) cannot flush mid-setup.
45+
OSOperationRepo.sharedInstance.paused = true
46+
OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 60_000
47+
}
48+
49+
override func tearDown() {
50+
resetOperationRepo()
51+
super.tearDown()
52+
}
53+
54+
func testFlush_sendsMatchedDeltasToExecutorAndClearsRepoQueue() {
55+
let executor = MockOperationExecutor(supportedDeltas: [knownDelta])
56+
let processExpectation = expectation(description: "processDeltaQueue")
57+
executor.onProcessDeltaQueue = { processExpectation.fulfill() }
58+
59+
let repo = OSOperationRepo.sharedInstance
60+
repo.addExecutor(executor)
61+
62+
let deltaA = makeDelta(name: knownDelta, property: "a")
63+
let deltaB = makeDelta(name: knownDelta, property: "b")
64+
repo.enqueueDelta(deltaA)
65+
repo.enqueueDelta(deltaB)
66+
waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 }
67+
68+
repo.paused = false
69+
repo.addFlushDeltaQueueToDispatchQueue()
70+
wait(for: [processExpectation], timeout: 2.0)
71+
72+
XCTAssertEqual(executor.enqueued.map(\.property), ["a", "b"])
73+
XCTAssertTrue(repo.deltaQueue.isEmpty)
74+
}
75+
76+
func testFlush_keepsUnmatchedDeltasInRepoQueue() {
77+
let executor = MockOperationExecutor(supportedDeltas: [knownDelta])
78+
let processExpectation = expectation(description: "processDeltaQueue")
79+
executor.onProcessDeltaQueue = { processExpectation.fulfill() }
80+
81+
let repo = OSOperationRepo.sharedInstance
82+
repo.addExecutor(executor)
83+
84+
let deltaA = makeDelta(name: unknownDelta, property: "a")
85+
let deltaB = makeDelta(name: unknownDelta, property: "b")
86+
repo.enqueueDelta(deltaA)
87+
repo.enqueueDelta(deltaB)
88+
waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 }
89+
90+
repo.paused = false
91+
repo.addFlushDeltaQueueToDispatchQueue()
92+
wait(for: [processExpectation], timeout: 2.0)
93+
94+
XCTAssertTrue(executor.enqueued.isEmpty)
95+
XCTAssertEqual(repo.deltaQueue.map(\.property), ["a", "b"])
96+
}
97+
98+
func testFlush_routesMatchedAndPreservesUnmatchedOrder() {
99+
let executor = MockOperationExecutor(supportedDeltas: [knownDelta])
100+
let processExpectation = expectation(description: "processDeltaQueue")
101+
executor.onProcessDeltaQueue = { processExpectation.fulfill() }
102+
103+
let repo = OSOperationRepo.sharedInstance
104+
repo.addExecutor(executor)
105+
106+
// Interleaved matched/unmatched: assert dispatch order and retained queue order.
107+
repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-1"))
108+
repo.enqueueDelta(makeDelta(name: unknownDelta, property: "unknown-1"))
109+
repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-2"))
110+
repo.enqueueDelta(makeDelta(name: unknownDelta, property: "unknown-2"))
111+
repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-3"))
112+
waitUntil("all deltas enqueued") { repo.deltaQueue.count == 5 }
113+
114+
repo.paused = false
115+
repo.addFlushDeltaQueueToDispatchQueue()
116+
wait(for: [processExpectation], timeout: 2.0)
117+
118+
XCTAssertEqual(executor.enqueued.map(\.property), ["known-1", "known-2", "known-3"])
119+
XCTAssertEqual(repo.deltaQueue.map(\.property), ["unknown-1", "unknown-2"])
120+
}
121+
122+
// MARK: - Helpers
123+
124+
private func resetOperationRepo() {
125+
let repo = OSOperationRepo.sharedInstance
126+
repo.deltaQueue.removeAll()
127+
repo.executors.removeAll()
128+
repo.deltasToExecutorMap.removeAll()
129+
repo.paused = false
130+
}
131+
132+
private func makeDelta(name: String, property: String) -> OSDelta {
133+
OSDelta(
134+
name: name,
135+
identityModelId: UUID().uuidString,
136+
model: OSModel(changeNotifier: OSEventProducer()),
137+
property: property,
138+
value: property
139+
)
140+
}
141+
142+
private func waitUntil(
143+
_ description: String,
144+
timeout: TimeInterval = 2.0,
145+
file: StaticString = #filePath,
146+
line: UInt = #line,
147+
_ condition: @escaping () -> Bool
148+
) {
149+
let exp = expectation(description: description)
150+
let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
151+
if condition() {
152+
timer.invalidate()
153+
exp.fulfill()
154+
}
155+
}
156+
let result = XCTWaiter.wait(for: [exp], timeout: timeout)
157+
timer.invalidate()
158+
XCTAssertEqual(result, .completed, "Timed out waiting for: \(description)", file: file, line: line)
159+
}
160+
}
161+
162+
private final class MockOperationExecutor: OSOperationExecutor {
163+
let supportedDeltas: [String]
164+
private(set) var enqueued: [OSDelta] = []
165+
var onProcessDeltaQueue: (() -> Void)?
166+
167+
init(supportedDeltas: [String]) {
168+
self.supportedDeltas = supportedDeltas
169+
}
170+
171+
func enqueueDelta(_ delta: OSDelta) {
172+
enqueued.append(delta)
173+
}
174+
175+
func cacheDeltaQueue() {}
176+
177+
func processDeltaQueue(inBackground: Bool) {
178+
onProcessDeltaQueue?()
179+
}
180+
}

0 commit comments

Comments
 (0)