Skip to content

Commit

Permalink
Implement RETRY room lifecycle operation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
lawrence-forooghian committed Nov 19, 2024
1 parent a04d20c commit c8c1574
Show file tree
Hide file tree
Showing 3 changed files with 550 additions and 13 deletions.
171 changes: 158 additions & 13 deletions Sources/AblyChat/RoomLifecycleManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,12 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
internal enum Status: Equatable {
case initialized
case attachingDueToAttachOperation(attachOperationID: UUID)
case attachingDueToRetryOperation(retryOperationID: UUID)
case attachingDueToContributorStateChange(error: ARTErrorInfo?)
case attached
case detaching(detachOperationID: UUID)
case detached
case detachedDueToRetryOperation(retryOperationID: UUID)
case suspendedAwaitingStartOfRetryOperation(error: ARTErrorInfo)
case suspended(retryOperationID: UUID, error: ARTErrorInfo)
case failed(error: ARTErrorInfo)
Expand All @@ -198,13 +200,15 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
.initialized
case .attachingDueToAttachOperation:
.attaching(error: nil)
case .attachingDueToRetryOperation:
.attaching(error: nil)
case let .attachingDueToContributorStateChange(error: error):
.attaching(error: error)
case .attached:
.attached
case .detaching:
.detaching
case .detached:
case .detached, .detachedDueToRetryOperation:
.detached
case let .suspendedAwaitingStartOfRetryOperation(error):
.suspended(error: error)
Expand All @@ -223,8 +227,12 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
switch self {
case let .attachingDueToAttachOperation(attachOperationID):
attachOperationID
case let .attachingDueToRetryOperation(retryOperationID):
retryOperationID
case let .detaching(detachOperationID):
detachOperationID
case let .detachedDueToRetryOperation(retryOperationID):
retryOperationID
case let .releasing(releaseOperationID):
releaseOperationID
case let .suspended(retryOperationID, _):
Expand Down Expand Up @@ -321,8 +329,12 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
logger.log(message: "Transitioning from \(status) to \(new)", level: .info)
let previous = status
status = new
let statusChange = RoomStatusChange(current: status.toRoomStatus, previous: previous.toRoomStatus)
emitStatusChange(statusChange)

// Avoid a double-emit of room status when changing from `.suspendedAwaitingStartOfRetryOperation` to `.suspended`.
if new.toRoomStatus != previous.toRoomStatus {
let statusChange = RoomStatusChange(current: status.toRoomStatus, previous: previous.toRoomStatus)
emitStatusChange(statusChange)
}
}

private func emitStatusChange(_ change: RoomStatusChange) {
Expand Down Expand Up @@ -709,7 +721,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
case .released:
// CHA-RL1c
throw ARTErrorInfo(chatError: .roomIsReleased)
case .initialized, .suspendedAwaitingStartOfRetryOperation, .suspended, .attachingDueToAttachOperation, .attachingDueToContributorStateChange, .detached, .detaching, .failed:
case .initialized, .suspendedAwaitingStartOfRetryOperation, .suspended, .attachingDueToAttachOperation, .attachingDueToRetryOperation, .attachingDueToContributorStateChange, .detached, .detachedDueToRetryOperation, .detaching, .failed:
break
}

Expand Down Expand Up @@ -825,7 +837,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor

private func bodyOfDetachOperation(operationID: UUID) async throws(ARTErrorInfo) {
switch status {
case .detached:
case .detached, .detachedDueToRetryOperation:
// CHA-RL2a
return
case .releasing:
Expand All @@ -837,22 +849,38 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
case .failed:
// CHA-RL2d
throw ARTErrorInfo(chatError: .roomInFailedState)
case .initialized, .suspendedAwaitingStartOfRetryOperation, .suspended, .attachingDueToAttachOperation, .attachingDueToContributorStateChange, .attached, .detaching:
case .initialized, .suspendedAwaitingStartOfRetryOperation, .suspended, .attachingDueToAttachOperation, .attachingDueToRetryOperation, .attachingDueToContributorStateChange, .attached, .detaching:
break
}

// CHA-RL2e
clearTransientDisconnectTimeouts()
changeStatus(to: .detaching(detachOperationID: operationID))

try await performDetachmentCycle()
try await performDetachmentCycle(trigger: .detachOperation)
}

/// Describes the reason a CHA-RL2f detachment cycle is being performed.
private enum DetachmentCycleTrigger {
case detachOperation
case retryOperation(retryOperationID: UUID, triggeringContributor: Contributor)

/// Given a CHA-RL2f detachment cycle triggered by this trigger, returns the DETACHED status to which the room should transition per CHA-RL2g.
var detachedStatus: Status {
switch self {
case .detachOperation:
.detached
case let .retryOperation(retryOperationID, _):
.detachedDueToRetryOperation(retryOperationID: retryOperationID)
}
}
}

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

// CHA-RL2g
changeStatus(to: .detached)
changeStatus(to: trigger.detachedStatus)
}

/// Returns the contributors that should be detached in a CHA-RL2f detachment cycle.
private func contributorsForDetachmentCycle(trigger: DetachmentCycleTrigger) -> [Contributor] {
switch trigger {
case .detachOperation:
// CHA-RL2f
contributors
case let .retryOperation(_, triggeringContributor):
// CHA-RL5a
contributors.filter { $0.id != triggeringContributor.id }
}
}

// MARK: - RELEASE operation
Expand Down Expand Up @@ -934,7 +974,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
case .released:
// CHA-RL3a
return
case .detached:
case .detached, .detachedDueToRetryOperation:
// CHA-RL3b
changeStatus(to: .released)
return
Expand All @@ -943,7 +983,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
// See note on waitForCompletionOfOperationWithID for the current need for this force try
// swiftlint:disable:next force_try
return try! await waitForCompletionOfOperationWithID(releaseOperationID, requester: .anotherOperation(operationID: operationID))
case .initialized, .attached, .attachingDueToAttachOperation, .attachingDueToContributorStateChange, .detaching, .suspendedAwaitingStartOfRetryOperation, .suspended, .failed:
case .initialized, .attached, .attachingDueToAttachOperation, .attachingDueToRetryOperation, .attachingDueToContributorStateChange, .detaching, .suspendedAwaitingStartOfRetryOperation, .suspended, .failed:
break
}

Expand Down Expand Up @@ -982,6 +1022,108 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
changeStatus(to: .released)
}

// MARK: - RETRY operation

/// Implements CHA-RL5’s RETRY operation.
///
/// - Parameters:
/// - 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.
/// - triggeringContributor: This is, in the language of CHA-RL5a, “the channel that became SUSPENDED”.
internal func performRetryOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil, triggeredByContributor triggeringContributor: Contributor, errorForSuspendedStatus: ARTErrorInfo) async {
// See note on performAnOperation for the current need for this force try
// swiftlint:disable:next force_try
try! await performAnOperation(forcingOperationID: forcedOperationID) { operationID in
await bodyOfRetryOperation(
operationID: operationID,
triggeredByContributor: triggeringContributor,
errorForSuspendedStatus: errorForSuspendedStatus
)
}
}

private func bodyOfRetryOperation(
operationID: UUID,
triggeredByContributor triggeringContributor: Contributor,
errorForSuspendedStatus: ARTErrorInfo
) async {
changeStatus(to: .suspended(retryOperationID: operationID, error: errorForSuspendedStatus))

// CHA-RL5a
do {
try await performDetachmentCycle(
trigger: .retryOperation(
retryOperationID: operationID,
triggeringContributor: triggeringContributor
)
)
} catch {
logger.log(message: "RETRY’s detachment cycle failed with error \(error). Ending RETRY.", level: .debug)
return
}

// CHA-RL5d
do {
try await waitForContributorThatTriggeredRetryToBecomeAttached(triggeringContributor)
} catch {
// CHA-RL5e
logger.log(message: "RETRY’s waiting for triggering contributor to attach failed with error \(error). Ending RETRY.", level: .debug)
return
}

// CHA-RL5f
changeStatus(to: .attachingDueToRetryOperation(retryOperationID: operationID))
do {
try await performAttachmentCycle()
} catch {
logger.log(message: "RETRY’s attachment cycle failed with error \(error). Ending RETRY.", level: .debug)
return
}
}

/// Performs CHA-RL5d’s “the room waits until the original channel that caused the retry loop naturally enters the ATTACHED state”.
///
/// Throws an error if the room enters the FAILED status, which is considered terminal by the RETRY operation.
private func waitForContributorThatTriggeredRetryToBecomeAttached(_ triggeringContributor: Contributor) async throws {
logger.log(message: "RETRY waiting for \(triggeringContributor) to enter ATTACHED", level: .debug)

let handleState = { [self] (state: ARTRealtimeChannelState, associatedError: ARTErrorInfo?) in
switch state {
// CHA-RL5d
case .attached:
logger.log(message: "RETRY completed waiting for \(triggeringContributor) to enter ATTACHED", level: .debug)
return true
// CHA-RL5e
case .failed:
guard let associatedError else {
preconditionFailure("Contributor entered FAILED but there’s no associated error")
}
logger.log(message: "RETRY failed waiting for \(triggeringContributor) to enter ATTACHED, since it entered FAILED with error \(associatedError)", level: .debug)

changeStatus(to: .failed(error: associatedError))
throw associatedError
case .attaching, .detached, .detaching, .initialized, .suspended:
return false
@unknown default:
return false
}
}

// 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).
// 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
if try await handleState(triggeringContributor.channel.state, triggeringContributor.channel.errorReason) {
return
}

// 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
for await stateChange in await triggeringContributor.channel.subscribeToState() {
// (I prefer this way of writing it, in this case)
// swiftlint:disable:next for_where
if try handleState(stateChange.current, stateChange.reason) {
return
}
}
}

// MARK: - Waiting to be able to perform presence operations

internal func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(ARTErrorInfo) {
Expand All @@ -992,10 +1134,13 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
case .attachingDueToContributorStateChange:
// TODO: Spec doesn’t say what to do in this situation; asked in https://github.com/ably/specification/issues/228
fatalError("waitToBeAbleToPerformPresenceOperations doesn’t currently handle attachingDueToContributorStateChange")
case .attachingDueToRetryOperation:
// TODO: Spec doesn’t say what to do in this situation; asked in https://github.com/ably/specification/issues/228
fatalError("waitToBeAbleToPerformPresenceOperations doesn’t currently handle attachingDueToRetryOperation")
case .attached:
// CHA-PR3e, CHA-PR11e, CHA-PR6d, CHA-T2d
break
case .detached:
case .detached, .detachedDueToRetryOperation:
// CHA-PR3f, CHA-PR11f, CHA-PR6e, CHA-T2e
throw .init(chatError: .presenceOperationRequiresRoomAttach(feature: requester))
case .detaching,
Expand Down
Loading

0 comments on commit c8c1574

Please sign in to comment.