Skip to content

Commit c8c1574

Browse files
Implement RETRY room lifecycle operation
Based on spec at bfcfa7e. The internal triggering of the RETRY operation (as specified by CHA-RL1h3 and CHA-RL4b9) will come in #50. Resolves #51.
1 parent a04d20c commit c8c1574

File tree

3 files changed

+550
-13
lines changed

3 files changed

+550
-13
lines changed

Sources/AblyChat/RoomLifecycleManager.swift

Lines changed: 158 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,12 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
182182
internal enum Status: Equatable {
183183
case initialized
184184
case attachingDueToAttachOperation(attachOperationID: UUID)
185+
case attachingDueToRetryOperation(retryOperationID: UUID)
185186
case attachingDueToContributorStateChange(error: ARTErrorInfo?)
186187
case attached
187188
case detaching(detachOperationID: UUID)
188189
case detached
190+
case detachedDueToRetryOperation(retryOperationID: UUID)
189191
case suspendedAwaitingStartOfRetryOperation(error: ARTErrorInfo)
190192
case suspended(retryOperationID: UUID, error: ARTErrorInfo)
191193
case failed(error: ARTErrorInfo)
@@ -198,13 +200,15 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
198200
.initialized
199201
case .attachingDueToAttachOperation:
200202
.attaching(error: nil)
203+
case .attachingDueToRetryOperation:
204+
.attaching(error: nil)
201205
case let .attachingDueToContributorStateChange(error: error):
202206
.attaching(error: error)
203207
case .attached:
204208
.attached
205209
case .detaching:
206210
.detaching
207-
case .detached:
211+
case .detached, .detachedDueToRetryOperation:
208212
.detached
209213
case let .suspendedAwaitingStartOfRetryOperation(error):
210214
.suspended(error: error)
@@ -223,8 +227,12 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
223227
switch self {
224228
case let .attachingDueToAttachOperation(attachOperationID):
225229
attachOperationID
230+
case let .attachingDueToRetryOperation(retryOperationID):
231+
retryOperationID
226232
case let .detaching(detachOperationID):
227233
detachOperationID
234+
case let .detachedDueToRetryOperation(retryOperationID):
235+
retryOperationID
228236
case let .releasing(releaseOperationID):
229237
releaseOperationID
230238
case let .suspended(retryOperationID, _):
@@ -321,8 +329,12 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
321329
logger.log(message: "Transitioning from \(status) to \(new)", level: .info)
322330
let previous = status
323331
status = new
324-
let statusChange = RoomStatusChange(current: status.toRoomStatus, previous: previous.toRoomStatus)
325-
emitStatusChange(statusChange)
332+
333+
// Avoid a double-emit of room status when changing from `.suspendedAwaitingStartOfRetryOperation` to `.suspended`.
334+
if new.toRoomStatus != previous.toRoomStatus {
335+
let statusChange = RoomStatusChange(current: status.toRoomStatus, previous: previous.toRoomStatus)
336+
emitStatusChange(statusChange)
337+
}
326338
}
327339

328340
private func emitStatusChange(_ change: RoomStatusChange) {
@@ -709,7 +721,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
709721
case .released:
710722
// CHA-RL1c
711723
throw ARTErrorInfo(chatError: .roomIsReleased)
712-
case .initialized, .suspendedAwaitingStartOfRetryOperation, .suspended, .attachingDueToAttachOperation, .attachingDueToContributorStateChange, .detached, .detaching, .failed:
724+
case .initialized, .suspendedAwaitingStartOfRetryOperation, .suspended, .attachingDueToAttachOperation, .attachingDueToRetryOperation, .attachingDueToContributorStateChange, .detached, .detachedDueToRetryOperation, .detaching, .failed:
713725
break
714726
}
715727

@@ -825,7 +837,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
825837

826838
private func bodyOfDetachOperation(operationID: UUID) async throws(ARTErrorInfo) {
827839
switch status {
828-
case .detached:
840+
case .detached, .detachedDueToRetryOperation:
829841
// CHA-RL2a
830842
return
831843
case .releasing:
@@ -837,22 +849,38 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
837849
case .failed:
838850
// CHA-RL2d
839851
throw ARTErrorInfo(chatError: .roomInFailedState)
840-
case .initialized, .suspendedAwaitingStartOfRetryOperation, .suspended, .attachingDueToAttachOperation, .attachingDueToContributorStateChange, .attached, .detaching:
852+
case .initialized, .suspendedAwaitingStartOfRetryOperation, .suspended, .attachingDueToAttachOperation, .attachingDueToRetryOperation, .attachingDueToContributorStateChange, .attached, .detaching:
841853
break
842854
}
843855

844856
// CHA-RL2e
845857
clearTransientDisconnectTimeouts()
846858
changeStatus(to: .detaching(detachOperationID: operationID))
847859

848-
try await performDetachmentCycle()
860+
try await performDetachmentCycle(trigger: .detachOperation)
861+
}
862+
863+
/// Describes the reason a CHA-RL2f detachment cycle is being performed.
864+
private enum DetachmentCycleTrigger {
865+
case detachOperation
866+
case retryOperation(retryOperationID: UUID, triggeringContributor: Contributor)
867+
868+
/// Given a CHA-RL2f detachment cycle triggered by this trigger, returns the DETACHED status to which the room should transition per CHA-RL2g.
869+
var detachedStatus: Status {
870+
switch self {
871+
case .detachOperation:
872+
.detached
873+
case let .retryOperation(retryOperationID, _):
874+
.detachedDueToRetryOperation(retryOperationID: retryOperationID)
875+
}
876+
}
849877
}
850878

851879
/// Performs the “CHA-RL2f detachment cycle”, to use the terminology of CHA-RL5a.
852-
private func performDetachmentCycle() async throws(ARTErrorInfo) {
880+
private func performDetachmentCycle(trigger: DetachmentCycleTrigger) async throws(ARTErrorInfo) {
853881
// CHA-RL2f
854882
var firstDetachError: ARTErrorInfo?
855-
for contributor in contributors {
883+
for contributor in contributorsForDetachmentCycle(trigger: trigger) {
856884
logger.log(message: "Detaching contributor \(contributor)", level: .info)
857885
do {
858886
try await contributor.channel.detach()
@@ -904,7 +932,19 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
904932
}
905933

906934
// CHA-RL2g
907-
changeStatus(to: .detached)
935+
changeStatus(to: trigger.detachedStatus)
936+
}
937+
938+
/// Returns the contributors that should be detached in a CHA-RL2f detachment cycle.
939+
private func contributorsForDetachmentCycle(trigger: DetachmentCycleTrigger) -> [Contributor] {
940+
switch trigger {
941+
case .detachOperation:
942+
// CHA-RL2f
943+
contributors
944+
case let .retryOperation(_, triggeringContributor):
945+
// CHA-RL5a
946+
contributors.filter { $0.id != triggeringContributor.id }
947+
}
908948
}
909949

910950
// MARK: - RELEASE operation
@@ -934,7 +974,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
934974
case .released:
935975
// CHA-RL3a
936976
return
937-
case .detached:
977+
case .detached, .detachedDueToRetryOperation:
938978
// CHA-RL3b
939979
changeStatus(to: .released)
940980
return
@@ -943,7 +983,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
943983
// See note on waitForCompletionOfOperationWithID for the current need for this force try
944984
// swiftlint:disable:next force_try
945985
return try! await waitForCompletionOfOperationWithID(releaseOperationID, requester: .anotherOperation(operationID: operationID))
946-
case .initialized, .attached, .attachingDueToAttachOperation, .attachingDueToContributorStateChange, .detaching, .suspendedAwaitingStartOfRetryOperation, .suspended, .failed:
986+
case .initialized, .attached, .attachingDueToAttachOperation, .attachingDueToRetryOperation, .attachingDueToContributorStateChange, .detaching, .suspendedAwaitingStartOfRetryOperation, .suspended, .failed:
947987
break
948988
}
949989

@@ -982,6 +1022,108 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
9821022
changeStatus(to: .released)
9831023
}
9841024

1025+
// MARK: - RETRY operation
1026+
1027+
/// Implements CHA-RL5’s RETRY operation.
1028+
///
1029+
/// - Parameters:
1030+
/// - forcedOperationID: Allows tests to force the operation to have a given ID. In combination with the ``testsOnly_subscribeToOperationWaitEvents`` API, this allows tests to verify that one test-initiated operation is waiting for another test-initiated operation.
1031+
/// - triggeringContributor: This is, in the language of CHA-RL5a, “the channel that became SUSPENDED”.
1032+
internal func performRetryOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil, triggeredByContributor triggeringContributor: Contributor, errorForSuspendedStatus: ARTErrorInfo) async {
1033+
// See note on performAnOperation for the current need for this force try
1034+
// swiftlint:disable:next force_try
1035+
try! await performAnOperation(forcingOperationID: forcedOperationID) { operationID in
1036+
await bodyOfRetryOperation(
1037+
operationID: operationID,
1038+
triggeredByContributor: triggeringContributor,
1039+
errorForSuspendedStatus: errorForSuspendedStatus
1040+
)
1041+
}
1042+
}
1043+
1044+
private func bodyOfRetryOperation(
1045+
operationID: UUID,
1046+
triggeredByContributor triggeringContributor: Contributor,
1047+
errorForSuspendedStatus: ARTErrorInfo
1048+
) async {
1049+
changeStatus(to: .suspended(retryOperationID: operationID, error: errorForSuspendedStatus))
1050+
1051+
// CHA-RL5a
1052+
do {
1053+
try await performDetachmentCycle(
1054+
trigger: .retryOperation(
1055+
retryOperationID: operationID,
1056+
triggeringContributor: triggeringContributor
1057+
)
1058+
)
1059+
} catch {
1060+
logger.log(message: "RETRY’s detachment cycle failed with error \(error). Ending RETRY.", level: .debug)
1061+
return
1062+
}
1063+
1064+
// CHA-RL5d
1065+
do {
1066+
try await waitForContributorThatTriggeredRetryToBecomeAttached(triggeringContributor)
1067+
} catch {
1068+
// CHA-RL5e
1069+
logger.log(message: "RETRY’s waiting for triggering contributor to attach failed with error \(error). Ending RETRY.", level: .debug)
1070+
return
1071+
}
1072+
1073+
// CHA-RL5f
1074+
changeStatus(to: .attachingDueToRetryOperation(retryOperationID: operationID))
1075+
do {
1076+
try await performAttachmentCycle()
1077+
} catch {
1078+
logger.log(message: "RETRY’s attachment cycle failed with error \(error). Ending RETRY.", level: .debug)
1079+
return
1080+
}
1081+
}
1082+
1083+
/// Performs CHA-RL5d’s “the room waits until the original channel that caused the retry loop naturally enters the ATTACHED state”.
1084+
///
1085+
/// Throws an error if the room enters the FAILED status, which is considered terminal by the RETRY operation.
1086+
private func waitForContributorThatTriggeredRetryToBecomeAttached(_ triggeringContributor: Contributor) async throws {
1087+
logger.log(message: "RETRY waiting for \(triggeringContributor) to enter ATTACHED", level: .debug)
1088+
1089+
let handleState = { [self] (state: ARTRealtimeChannelState, associatedError: ARTErrorInfo?) in
1090+
switch state {
1091+
// CHA-RL5d
1092+
case .attached:
1093+
logger.log(message: "RETRY completed waiting for \(triggeringContributor) to enter ATTACHED", level: .debug)
1094+
return true
1095+
// CHA-RL5e
1096+
case .failed:
1097+
guard let associatedError else {
1098+
preconditionFailure("Contributor entered FAILED but there’s no associated error")
1099+
}
1100+
logger.log(message: "RETRY failed waiting for \(triggeringContributor) to enter ATTACHED, since it entered FAILED with error \(associatedError)", level: .debug)
1101+
1102+
changeStatus(to: .failed(error: associatedError))
1103+
throw associatedError
1104+
case .attaching, .detached, .detaching, .initialized, .suspended:
1105+
return false
1106+
@unknown default:
1107+
return false
1108+
}
1109+
}
1110+
1111+
// Check whether the contributor is already in one of the states that we’re going to wait for. CHA-RL5d doesn’t make this check explicit but it seems like the right thing to do (asked in https://github.com/ably/specification/issues/221).
1112+
// TODO: this assumes that if you fetch a channel’s `state` and then its `errorReason`, they will both refer to the same channel state; this may not be true due to threading, address in https://github.com/ably-labs/ably-chat-swift/issues/49
1113+
if try await handleState(triggeringContributor.channel.state, triggeringContributor.channel.errorReason) {
1114+
return
1115+
}
1116+
1117+
// TODO: this assumes that if you check a channel’s state, and it’s x, and you then immediately add a state listener, you’ll definitely find out if the channel changes to a state other than x; this may not be true due to threading, address in https://github.com/ably-labs/ably-chat-swift/issues/49
1118+
for await stateChange in await triggeringContributor.channel.subscribeToState() {
1119+
// (I prefer this way of writing it, in this case)
1120+
// swiftlint:disable:next for_where
1121+
if try handleState(stateChange.current, stateChange.reason) {
1122+
return
1123+
}
1124+
}
1125+
}
1126+
9851127
// MARK: - Waiting to be able to perform presence operations
9861128

9871129
internal func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(ARTErrorInfo) {
@@ -992,10 +1134,13 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
9921134
case .attachingDueToContributorStateChange:
9931135
// TODO: Spec doesn’t say what to do in this situation; asked in https://github.com/ably/specification/issues/228
9941136
fatalError("waitToBeAbleToPerformPresenceOperations doesn’t currently handle attachingDueToContributorStateChange")
1137+
case .attachingDueToRetryOperation:
1138+
// TODO: Spec doesn’t say what to do in this situation; asked in https://github.com/ably/specification/issues/228
1139+
fatalError("waitToBeAbleToPerformPresenceOperations doesn’t currently handle attachingDueToRetryOperation")
9951140
case .attached:
9961141
// CHA-PR3e, CHA-PR11e, CHA-PR6d, CHA-T2d
9971142
break
998-
case .detached:
1143+
case .detached, .detachedDueToRetryOperation:
9991144
// CHA-PR3f, CHA-PR11f, CHA-PR6e, CHA-T2e
10001145
throw .init(chatError: .presenceOperationRequiresRoomAttach(feature: requester))
10011146
case .detaching,

0 commit comments

Comments
 (0)