Skip to content

Commit

Permalink
Integrate lifecycle manager into room operations
Browse files Browse the repository at this point in the history
Replace the existing temporary implementations of room attach / detach /
status with those provided by the room lifecycle manager.

Part of #47.
  • Loading branch information
lawrence-forooghian committed Nov 10, 2024
1 parent eb62f71 commit 79885d7
Show file tree
Hide file tree
Showing 12 changed files with 300 additions and 187 deletions.
3 changes: 2 additions & 1 deletion Sources/AblyChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public actor DefaultChatClient: ChatClient {
self.realtime = realtime
self.clientOptions = clientOptions ?? .init()
logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel)
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger)
let roomLifecycleManagerFactory = DefaultRoomLifecycleManagerFactory()
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, lifecycleManagerFactory: roomLifecycleManagerFactory)
}

public nonisolated var connection: any Connection {
Expand Down
48 changes: 48 additions & 0 deletions Sources/AblyChat/DefaultRoomLifecycleContributor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Ably

internal actor DefaultRoomLifecycleContributor: RoomLifecycleContributor {
internal let channel: DefaultRoomLifecycleContributorChannel
internal let feature: RoomFeature

internal init(channel: DefaultRoomLifecycleContributorChannel, feature: RoomFeature) {
self.channel = channel
self.feature = feature
}

// MARK: - Discontinuities

internal func emitDiscontinuity(_: ARTErrorInfo) {
// TODO: https://github.com/ably-labs/ably-chat-swift/issues/47
}
}

internal final class DefaultRoomLifecycleContributorChannel: RoomLifecycleContributorChannel {
private let underlyingChannel: any RealtimeChannelProtocol

internal init(underlyingChannel: any RealtimeChannelProtocol) {
self.underlyingChannel = underlyingChannel
}

internal func attach() async throws(ARTErrorInfo) {
try await underlyingChannel.attachAsync()
}

internal func detach() async throws(ARTErrorInfo) {
try await underlyingChannel.detachAsync()
}

internal var state: ARTRealtimeChannelState {
underlyingChannel.state
}

internal var errorReason: ARTErrorInfo? {
underlyingChannel.errorReason
}

internal func subscribeToState() async -> Subscription<ARTChannelStateChange> {
// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
let subscription = Subscription<ARTChannelStateChange>(bufferingPolicy: .unbounded)
underlyingChannel.on { subscription.emit($0) }
return subscription
}
}
66 changes: 27 additions & 39 deletions Sources/AblyChat/Room.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public protocol Room: AnyObject, Sendable {
var options: RoomOptions { get }
}

public struct RoomStatusChange: Sendable {
public struct RoomStatusChange: Sendable, Equatable {
public var current: RoomStatus
public var previous: RoomStatus

Expand All @@ -29,7 +29,7 @@ public struct RoomStatusChange: Sendable {
}
}

internal actor DefaultRoom: Room {
internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory<DefaultRoomLifecycleContributor>>: Room {
internal nonisolated let roomID: String
internal nonisolated let options: RoomOptions
private let chatAPI: ChatAPI
Expand All @@ -39,21 +39,17 @@ internal actor DefaultRoom: Room {
// Exposed for testing.
private nonisolated let realtime: RealtimeClient

/// The channels that contribute to this room.
private let channels: [RoomFeature: RealtimeChannelProtocol]
private let lifecycleManager: any RoomLifecycleManager

#if DEBUG
internal nonisolated var testsOnly_realtime: RealtimeClient {
realtime
}
#endif

internal private(set) var status: RoomStatus = .initialized
// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
private var statusSubscriptions: [Subscription<RoomStatusChange>] = []
private let logger: InternalLogger

internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws {
internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) async throws {
self.realtime = realtime
self.roomID = roomID
self.options = options
Expand All @@ -64,7 +60,13 @@ internal actor DefaultRoom: Room {
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.")
}

channels = Self.createChannels(roomID: roomID, realtime: realtime)
let channels = Self.createChannels(roomID: roomID, realtime: realtime)
let contributors = Self.createContributors(channels: channels)

lifecycleManager = await lifecycleManagerFactory.createManager(
contributors: contributors,
logger: logger
)

messages = await DefaultMessages(
channel: channels[.messages]!,
Expand All @@ -75,12 +77,20 @@ internal actor DefaultRoom: Room {
}

private static func createChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: RealtimeChannelProtocol] {
.init(uniqueKeysWithValues: [RoomFeature.messages, RoomFeature.typing, RoomFeature.reactions].map { feature in
.init(uniqueKeysWithValues: [RoomFeature.messages].map { feature in
let channel = realtime.getChannel(feature.channelNameForRoomID(roomID))

return (feature, channel)
})
}

private static func createContributors(channels: [RoomFeature: RealtimeChannelProtocol]) -> [DefaultRoomLifecycleContributor] {
channels.map { entry in
let (feature, channel) = entry
return .init(channel: .init(underlyingChannel: channel), feature: feature)
}
}

public nonisolated var presence: any Presence {
fatalError("Not yet implemented")
}
Expand All @@ -98,44 +108,22 @@ internal actor DefaultRoom: Room {
}

public func attach() async throws {
for channel in channels.map(\.value) {
do {
try await channel.attachAsync()
} catch {
logger.log(message: "Failed to attach channel \(channel), error \(error)", level: .error)
throw error
}
}
transition(to: .attached)
try await lifecycleManager.performAttachOperation()
}

public func detach() async throws {
for channel in channels.map(\.value) {
do {
try await channel.detachAsync()
} catch {
logger.log(message: "Failed to detach channel \(channel), error \(error)", level: .error)
throw error
}
}
transition(to: .detached)
try await lifecycleManager.performDetachOperation()
}

// MARK: - Room status

internal func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription<RoomStatusChange> {
let subscription: Subscription<RoomStatusChange> = .init(bufferingPolicy: bufferingPolicy)
statusSubscriptions.append(subscription)
return subscription
internal func onStatusChange(bufferingPolicy: BufferingPolicy) async -> Subscription<RoomStatusChange> {
await lifecycleManager.onChange(bufferingPolicy: bufferingPolicy)
}

/// Sets ``status`` to the given status, and emits a status change to all subscribers added via ``onStatusChange(bufferingPolicy:)``.
internal func transition(to newStatus: RoomStatus) {
logger.log(message: "Transitioning to \(newStatus)", level: .debug)
let statusChange = RoomStatusChange(current: newStatus, previous: status)
status = newStatus
for subscription in statusSubscriptions {
subscription.emit(statusChange)
internal var status: RoomStatus {
get async {
await lifecycleManager.roomStatus
}
}
}
6 changes: 1 addition & 5 deletions Sources/AblyChat/RoomFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ internal enum RoomFeature {
case .messages:
// (CHA-M1) Chat messages for a Room are sent on a corresponding realtime channel <roomId>::$chat::$chatMessages. For example, if your room id is my-room then the messages channel will be my-room::$chat::$chatMessages.
"chatMessages"
case .typing:
"typingIndicators"
case .reactions:
"reactions"
case .presence, .occupancy:
case .typing, .reactions, .presence, .occupancy:
// We’ll add these, with reference to the relevant spec points, as we implement these features
fatalError("Don’t know channel name suffix for room feature \(self)")
}
Expand Down
50 changes: 47 additions & 3 deletions Sources/AblyChat/RoomLifecycleManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,35 @@ internal protocol RoomLifecycleContributor: Identifiable, Sendable {
func emitDiscontinuity(_ error: ARTErrorInfo) async
}

internal protocol RoomLifecycleManager: Sendable {}
internal protocol RoomLifecycleManager: Sendable {
func performAttachOperation() async throws
func performDetachOperation() async throws
var roomStatus: RoomStatus { get async }
func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription<RoomStatusChange>
}

internal protocol RoomLifecycleManagerFactory<Contributor>: Sendable {
associatedtype Contributor: RoomLifecycleContributor
associatedtype Manager: RoomLifecycleManager

func createManager(
contributors: [Contributor],
logger: InternalLogger
) async -> Manager
}

internal final class DefaultRoomLifecycleManagerFactory: RoomLifecycleManagerFactory {
internal func createManager(
contributors: [DefaultRoomLifecycleContributor],
logger: InternalLogger
) async -> DefaultRoomLifecycleManager<DefaultRoomLifecycleContributor> {
await .init(
contributors: contributors,
logger: logger,
clock: DefaultSimpleClock()
)
}
}

internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor>: RoomLifecycleManager {
// MARK: - Constant properties
Expand Down Expand Up @@ -615,11 +643,19 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor

// MARK: - ATTACH operation

internal func performAttachOperation() async throws {
try await _performAttachOperation(forcingOperationID: nil)
}

internal func performAttachOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async throws {
try await _performAttachOperation(forcingOperationID: forcedOperationID)
}

/// Implements CHA-RL1’s `ATTACH` 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.
internal func performAttachOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async throws {
private func _performAttachOperation(forcingOperationID forcedOperationID: UUID?) async throws {
try await performAnOperation(forcingOperationID: forcedOperationID) { operationID in
try await bodyOfAttachOperation(operationID: operationID)
}
Expand Down Expand Up @@ -727,11 +763,19 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor

// MARK: - DETACH operation

internal func performDetachOperation() async throws {
try await _performDetachOperation(forcingOperationID: nil)
}

internal func performDetachOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async throws {
try await _performDetachOperation(forcingOperationID: forcedOperationID)
}

/// Implements CHA-RL2’s DETACH 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.
internal func performDetachOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async throws {
private func _performDetachOperation(forcingOperationID forcedOperationID: UUID?) async throws {
try await performAnOperation(forcingOperationID: forcedOperationID) { operationID in
try await bodyOfDetachOperation(operationID: operationID)
}
Expand Down
10 changes: 6 additions & 4 deletions Sources/AblyChat/Rooms.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public protocol Rooms: AnyObject, Sendable {
var clientOptions: ClientOptions { get }
}

internal actor DefaultRooms: Rooms {
internal actor DefaultRooms<LifecycleManagerFactory: RoomLifecycleManagerFactory<DefaultRoomLifecycleContributor>>: Rooms {
private nonisolated let realtime: RealtimeClient
private let chatAPI: ChatAPI

Expand All @@ -19,14 +19,16 @@ internal actor DefaultRooms: Rooms {
internal nonisolated let clientOptions: ClientOptions

private let logger: InternalLogger
private let lifecycleManagerFactory: LifecycleManagerFactory

/// The set of rooms, keyed by room ID.
private var rooms: [String: DefaultRoom] = [:]
private var rooms: [String: DefaultRoom<LifecycleManagerFactory>] = [:]

internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger) {
internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) {
self.realtime = realtime
self.clientOptions = clientOptions
self.logger = logger
self.lifecycleManagerFactory = lifecycleManagerFactory
chatAPI = ChatAPI(realtime: realtime)
}

Expand All @@ -41,7 +43,7 @@ internal actor DefaultRooms: Rooms {

return existingRoom
} else {
let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger)
let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger, lifecycleManagerFactory: lifecycleManagerFactory)
rooms[roomID] = room
return room
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/AblyChat/SimpleClock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ internal protocol SimpleClock: Sendable {
/// Behaves like `Task.sleep(nanoseconds:)`. Uses seconds instead of nanoseconds for readability at call site (we have no need for that level of precision).
func sleep(timeInterval: TimeInterval) async throws
}

internal final class DefaultSimpleClock: SimpleClock {
internal func sleep(timeInterval: TimeInterval) async throws {
try await Task.sleep(nanoseconds: UInt64(timeInterval * Double(NSEC_PER_SEC)))
}
}
2 changes: 1 addition & 1 deletion Tests/AblyChatTests/DefaultChatClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct DefaultChatClientTests {
// Then: Its `rooms` property returns an instance of DefaultRooms with the same realtime client and client options
let rooms = client.rooms

let defaultRooms = try #require(rooms as? DefaultRooms)
let defaultRooms = try #require(rooms as? DefaultRooms<DefaultRoomLifecycleManagerFactory>)
#expect(defaultRooms.testsOnly_realtime === realtime)
#expect(defaultRooms.clientOptions.isEqualForTestPurposes(options))
}
Expand Down
Loading

0 comments on commit 79885d7

Please sign in to comment.