Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ECO-4982] Integrate lifecycle manager into existing room operations #106

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
}
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved

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
}
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved
}
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>: Room where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor {
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))

lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved
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)
}
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved

/// 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
}
}
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved
}
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
52 changes: 49 additions & 3 deletions Sources/AblyChat/RoomLifecycleManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,37 @@ 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: Sendable {
associatedtype Contributor: RoomLifecycleContributor
associatedtype Manager: RoomLifecycleManager

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

internal final class DefaultRoomLifecycleManagerFactory: RoomLifecycleManagerFactory {
private let clock = DefaultSimpleClock()

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

internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor>: RoomLifecycleManager {
// MARK: - Constant properties
Expand Down Expand Up @@ -615,11 +645,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 +765,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>: Rooms where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor {
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
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved
}
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