Skip to content

Commit e264b67

Browse files
authored
fix(datastore): Keep DataStore sync engine running even if models subscriptions fail (#815)
* fix(datastore): keep DataStore sync engine running even if models subscriptions fail * test(datastore): add sync engine start unit tests * fix(datastore): move authorization errors handling responsibility to AWSModelReconciliationQueue * fix(datastore): clear modelReconciliationQueueSinks if a subscription fail w/ unauthorized err
1 parent 01fa243 commit e264b67

File tree

5 files changed

+135
-12
lines changed

5 files changed

+135
-12
lines changed

AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/AWSIncomingEventReconciliationQueue.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ final class AWSIncomingEventReconciliationQueue: IncomingEventReconciliationQueu
4141
private var reconciliationQueues: [String: ModelReconciliationQueue]
4242
private var reconciliationQueueConnectionStatus: [String: Bool]
4343
private var modelReconciliationQueueFactory: ModelReconciliationQueueFactory
44+
45+
private var isInitialized: Bool {
46+
reconciliationQueueConnectionStatus.count == reconciliationQueues.count
47+
}
4448

4549
init(modelTypes: [Model.Type],
4650
api: APICategoryGraphQLBehavior,
@@ -107,10 +111,19 @@ final class AWSIncomingEventReconciliationQueue: IncomingEventReconciliationQueu
107111
switch receiveValue {
108112
case .mutationEvent(let event):
109113
eventReconciliationQueueTopic.send(.mutationEvent(event))
110-
case .connected(let modelName):
114+
case .connected(modelName: let modelName):
111115
connectionStatusSerialQueue.async {
112116
self.reconciliationQueueConnectionStatus[modelName] = true
113-
if self.reconciliationQueueConnectionStatus.count == self.reconciliationQueues.count {
117+
if self.isInitialized {
118+
self.eventReconciliationQueueTopic.send(.initialized)
119+
}
120+
}
121+
case .disconnected(modelName: let modelName, reason: .unauthorized):
122+
connectionStatusSerialQueue.async {
123+
self.reconciliationQueues[modelName]?.cancel()
124+
self.modelReconciliationQueueSinks[modelName]?.cancel()
125+
self.reconciliationQueueConnectionStatus[modelName] = false
126+
if self.isInitialized {
114127
self.eventReconciliationQueueTopic.send(.initialized)
115128
}
116129
}

AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/AWSIncomingSubscriptionEventPublisher.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ final class AWSIncomingSubscriptionEventPublisher: IncomingSubscriptionEventPubl
5858
}
5959
}
6060

61+
// MARK: Resettable
6162
@available(iOS 13.0, *)
6263
extension AWSIncomingSubscriptionEventPublisher: Resettable {
6364

AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ final class AWSModelReconciliationQueue: ModelReconciliationQueue {
154154
self.enqueue(remoteModel)
155155
})
156156
case .connectionConnected:
157-
modelReconciliationQueueSubject.send(.connected(modelName))
157+
modelReconciliationQueueSubject.send(.connected(modelName: modelName))
158158
}
159159
}
160160

@@ -164,6 +164,11 @@ final class AWSModelReconciliationQueue: ModelReconciliationQueue {
164164
log.info("receivedCompletion: finished")
165165
modelReconciliationQueueSubject.send(completion: .finished)
166166
case .failure(let dataStoreError):
167+
if case let .api(error, _) = dataStoreError,
168+
case let APIError.operationError(_, _, underlyingError) = error, isUnauthorizedError(underlyingError) {
169+
modelReconciliationQueueSubject.send(.disconnected(modelName: modelName, reason: .unauthorized))
170+
return
171+
}
167172
log.error("receiveCompletion: error: \(dataStoreError)")
168173
modelReconciliationQueueSubject.send(completion: .failure(dataStoreError))
169174
}
@@ -173,6 +178,7 @@ final class AWSModelReconciliationQueue: ModelReconciliationQueue {
173178
@available(iOS 13.0, *)
174179
extension AWSModelReconciliationQueue: DefaultLogger { }
175180

181+
// MARK: Resettable
176182
@available(iOS 13.0, *)
177183
extension AWSModelReconciliationQueue: Resettable {
178184

@@ -208,3 +214,26 @@ extension AWSModelReconciliationQueue: Resettable {
208214
}
209215

210216
}
217+
218+
// MARK: Auth errors handling
219+
@available(iOS 13.0, *)
220+
extension AWSModelReconciliationQueue {
221+
private typealias ResponseType = MutationSync<AnyModel>
222+
private func graphqlErrors(from error: GraphQLResponseError<ResponseType>?) -> [GraphQLError]? {
223+
if case let .error(errors) = error {
224+
return errors
225+
}
226+
return nil
227+
}
228+
229+
private func isUnauthorizedError(_ error: Error?) -> Bool {
230+
if let responseError = error as? GraphQLResponseError<ResponseType>,
231+
let graphQLError = graphqlErrors(from: responseError)?.first,
232+
let extensions = graphQLError.extensions,
233+
case let .string(errorTypeValue) = extensions["errorType"],
234+
case .unauthorized = AppSyncErrorType(errorTypeValue) {
235+
return true
236+
}
237+
return false
238+
}
239+
}

AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ModelReconciliationQueue.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ import Amplify
99
import AWSPluginsCore
1010
import Combine
1111

12+
enum ModelConnectionDisconnectedReason {
13+
case unauthorized
14+
}
15+
1216
enum ModelReconciliationQueueEvent {
1317
case started
1418
case paused
15-
case connected(String)
19+
case connected(modelName: String)
20+
case disconnected(modelName: String, reason: ModelConnectionDisconnectedReason)
1621
case mutationEvent(MutationEvent)
1722
}
1823

AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/SubscriptionSync/AWSIncomingEventReconciliationQueueTests.swift

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ class AWSIncomingEventReconciliationQueueTests: XCTestCase {
2525

2626
apiPlugin = MockAPICategoryPlugin()
2727

28+
operationQueue = OperationQueue()
29+
operationQueue.name = "com.amazonaws.DataStore.UnitTestQueue"
30+
operationQueue.maxConcurrentOperationCount = 2
31+
operationQueue.underlyingQueue = DispatchQueue.global()
32+
operationQueue.isSuspended = true
33+
2834
}
2935
var operationQueue: OperationQueue!
3036

@@ -50,20 +56,14 @@ class AWSIncomingEventReconciliationQueueTests: XCTestCase {
5056
case .initialized:
5157
expectInitialized.fulfill()
5258
default:
53-
XCTFail("Should not expect any other state")
59+
XCTFail("Should not expect any other state, received: \(event)")
5460
}
5561
})
5662

57-
operationQueue = OperationQueue()
58-
operationQueue.name = "com.amazonaws.DataStore.UnitTestQueue"
59-
operationQueue.maxConcurrentOperationCount = 2
60-
operationQueue.underlyingQueue = DispatchQueue.global()
61-
operationQueue.isSuspended = true
62-
6363
let reconciliationQueues = MockModelReconciliationQueue.mockModelReconciliationQueues
6464
for (queueName, queue) in reconciliationQueues {
6565
let cancellableOperation = CancelAwareBlockOperation {
66-
queue.modelReconciliationQueueSubject.send(.connected(queueName))
66+
queue.modelReconciliationQueueSubject.send(.connected(modelName: queueName))
6767
}
6868
operationQueue.addOperation(cancellableOperation)
6969
}
@@ -73,4 +73,79 @@ class AWSIncomingEventReconciliationQueueTests: XCTestCase {
7373
// Take action on the sink to prevent compiler warnings about unused variables.
7474
sink.cancel()
7575
}
76+
77+
func testSubscriptionFailedWithSingleModelUnauthorizedError() {
78+
let expectInitialized = expectation(description: "eventQueue expected to send out initialized state")
79+
let modelReconciliationQueueFactory
80+
= MockModelReconciliationQueue.init(modelType:storageAdapter:api:auth:incomingSubscriptionEvents:)
81+
let eventQueue = AWSIncomingEventReconciliationQueue(
82+
modelTypes: [Post.self],
83+
api: apiPlugin,
84+
storageAdapter: storageAdapter,
85+
modelReconciliationQueueFactory: modelReconciliationQueueFactory)
86+
eventQueue.start()
87+
88+
let sink = eventQueue.publisher.sink(receiveCompletion: { _ in
89+
XCTFail("Not expecting this to call")
90+
}, receiveValue: { event in
91+
switch event {
92+
case .initialized:
93+
expectInitialized.fulfill()
94+
default:
95+
XCTFail("Should not expect any other state, received: \(event)")
96+
}
97+
})
98+
99+
let reconciliationQueues = MockModelReconciliationQueue.mockModelReconciliationQueues
100+
for (queueName, queue) in reconciliationQueues {
101+
let cancellableOperation = CancelAwareBlockOperation {
102+
queue.modelReconciliationQueueSubject.send(.disconnected(modelName: queueName, reason: .unauthorized))
103+
}
104+
operationQueue.addOperation(cancellableOperation)
105+
}
106+
operationQueue.isSuspended = false
107+
waitForExpectations(timeout: 2)
108+
109+
sink.cancel()
110+
}
111+
112+
// This test case tests that initialized event is received even if only one
113+
// model subscriptions out of two failed - Post subscription will fail but Comment will succeed
114+
func testSubscriptionFailedWithMultipleModels() {
115+
let expectInitialized = expectation(description: "eventQueue expected to send out initialized state")
116+
let modelReconciliationQueueFactory
117+
= MockModelReconciliationQueue.init(modelType:storageAdapter:api:auth:incomingSubscriptionEvents:)
118+
let eventQueue = AWSIncomingEventReconciliationQueue(
119+
modelTypes: [Post.self, Comment.self],
120+
api: apiPlugin,
121+
storageAdapter: storageAdapter,
122+
modelReconciliationQueueFactory: modelReconciliationQueueFactory)
123+
eventQueue.start()
124+
125+
let sink = eventQueue.publisher.sink(receiveCompletion: { _ in
126+
XCTFail("Not expecting this to call")
127+
}, receiveValue: { event in
128+
switch event {
129+
case .initialized:
130+
expectInitialized.fulfill()
131+
default:
132+
XCTFail("Should not expect any other state, received: \(event)")
133+
}
134+
})
135+
136+
let reconciliationQueues = MockModelReconciliationQueue.mockModelReconciliationQueues
137+
for (queueName, queue) in reconciliationQueues {
138+
let cancellableOperation = CancelAwareBlockOperation {
139+
let event: ModelReconciliationQueueEvent = queueName == Post.modelName ?
140+
.disconnected(modelName: queueName, reason: .unauthorized) :
141+
.connected(modelName: queueName)
142+
queue.modelReconciliationQueueSubject.send(event)
143+
}
144+
operationQueue.addOperation(cancellableOperation)
145+
}
146+
operationQueue.isSuspended = false
147+
waitForExpectations(timeout: 2)
148+
149+
sink.cancel()
150+
}
76151
}

0 commit comments

Comments
 (0)