@@ -334,11 +334,73 @@ public final class KafkaConsumer: Sendable, Service {
334334 )
335335 }
336336
337- /// Subscribe to the given list of `topics`.
338- /// The partition assignment happens automatically using `KafkaConsumer`'s consumer group.
337+ // MARK: - Subscription Management
338+
339+ /// Subscribe to the given list of topics.
340+ ///
341+ /// This replaces any previous subscription. The partition assignment happens
342+ /// automatically using the consumer's consumer group. Topic names prefixed
343+ /// with `^` are treated as regular expressions. Passing an empty array is
344+ /// equivalent to calling ``unsubscribe()``.
345+ ///
339346 /// - Parameter topics: An array of topic names to subscribe to.
340- /// - Throws: A ``KafkaError`` if subscribing to the topic list failed.
341- private func subscribe( topics: [ String ] ) throws {
347+ /// - Throws: A ``KafkaError`` if the consumer is closed or subscribing failed.
348+ public func subscribe( topics: [ String ] ) throws {
349+ guard !topics. isEmpty else {
350+ try self . unsubscribe ( )
351+ return
352+ }
353+
354+ let action = self . stateMachine. withLockedValue { $0. withClientForSubscription ( ) }
355+ switch action {
356+ case . client( let client) :
357+ let subscription = RDKafkaTopicPartitionList ( )
358+ for topic in topics {
359+ subscription. add (
360+ topic: topic,
361+ partition: KafkaPartition . unassigned
362+ )
363+ }
364+ try client. subscribe ( topicPartitionList: subscription)
365+ case . throwClosedError:
366+ throw KafkaError . connectionClosed ( reason: " Consumer is closed " )
367+ }
368+ }
369+
370+ /// Unsubscribe from the current subscription.
371+ ///
372+ /// Clears all topic subscriptions. The consumer leaves the consumer group
373+ /// and stops receiving messages. This triggers a rebalance event.
374+ /// Can re-subscribe later with ``subscribe(topics:)``.
375+ ///
376+ /// - Throws: A ``KafkaError`` if the consumer is closed or unsubscribing failed.
377+ public func unsubscribe( ) throws {
378+ let action = self . stateMachine. withLockedValue { $0. withClientForSubscription ( ) }
379+ switch action {
380+ case . client( let client) :
381+ try client. unsubscribe ( )
382+ case . throwClosedError:
383+ throw KafkaError . connectionClosed ( reason: " Consumer is closed " )
384+ }
385+ }
386+
387+ /// Get the current topic subscription.
388+ ///
389+ /// - Returns: An array of topic names or patterns the consumer is currently subscribed to.
390+ /// - Throws: A ``KafkaError`` if the consumer is closed or the query failed.
391+ public func subscribedTopics( ) throws -> [ String ] {
392+ let action = self . stateMachine. withLockedValue { $0. withClientForSubscription ( ) }
393+ switch action {
394+ case . client( let client) :
395+ return try client. subscription ( )
396+ case . throwClosedError:
397+ throw KafkaError . connectionClosed ( reason: " Consumer is closed " )
398+ }
399+ }
400+
401+ /// Internal startup subscription that transitions the state machine from `.initializing` to `.running`.
402+ /// Called once during `_run()` to set up the initial subscription from `consumptionStrategy`.
403+ private func initialSubscribe( topics: [ String ] ) throws {
342404 let action = self . stateMachine. withLockedValue { $0. setUpConnection ( ) }
343405 switch action {
344406 case . setUpConnection( let client) :
@@ -389,11 +451,23 @@ public final class KafkaConsumer: Sendable, Service {
389451 }
390452
391453 private func _run( ) async throws {
392- switch self . config. consumptionStrategy!. _internal {
393- case . partition( groupID: _, let topic, let partition, let offset) :
394- try self . assign ( topic: topic, partition: partition, offset: offset)
395- case . group( groupID: _, let topics) :
396- try self . subscribe ( topics: topics)
454+ if let strategy = self . config. consumptionStrategy? . _internal {
455+ switch strategy {
456+ case . partition( groupID: _, let topic, let partition, let offset) :
457+ try self . assign ( topic: topic, partition: partition, offset: offset)
458+ case . group( groupID: _, let topics) :
459+ try self . initialSubscribe ( topics: topics)
460+ }
461+ } else {
462+ // No consumptionStrategy set — user will call subscribe(topics:) manually.
463+ // Transition state machine to .running so the event loop can start.
464+ let action = self . stateMachine. withLockedValue { $0. setUpConnection ( ) }
465+ switch action {
466+ case . setUpConnection:
467+ break
468+ case . consumerClosed:
469+ throw KafkaError . connectionClosed ( reason: " Consumer closed before run " )
470+ }
397471 }
398472 try await self . eventRunLoop ( )
399473 }
@@ -907,19 +981,32 @@ extension KafkaConsumer {
907981
908982 /// Get the running client for performing an operation that requires an active consumer.
909983 ///
910- /// This is a general-purpose accessor used by methods that need the underlying
911- /// `RDKafkaClient` while the consumer is running (e.g., commit, storeOffset,
912- /// committed, position, seek, isAssignmentLost).
913- ///
914984 /// - Returns: The action to be taken.
915- ///
916- /// - Important: This function calls `fatalError` if called while in the `.initializing` state.
917985 func withClient( ) -> ClientAction {
918986 switch self . state {
919987 case . uninitialized:
920988 fatalError ( " \( #function) invoked while still in state \( self . state) " )
921989 case . initializing:
922- fatalError ( " Subscribe to consumer group / assign to topic partition pair before reading messages " )
990+ return . throwClosedError
991+ case . running( let client, _) :
992+ return . client( client)
993+ case . finishing, . finished:
994+ return . throwClosedError
995+ }
996+ }
997+
998+ /// Get the client for subscription management operations.
999+ ///
1000+ /// Unlike ``withClient()``, this accessor works in both `.initializing` and `.running`
1001+ /// states, allowing subscribe/unsubscribe to be called at any time after consumer creation.
1002+ ///
1003+ /// - Returns: The action to be taken.
1004+ func withClientForSubscription( ) -> ClientAction {
1005+ switch self . state {
1006+ case . uninitialized:
1007+ fatalError ( " \( #function) invoked while still in state \( self . state) " )
1008+ case . initializing( let client, _) :
1009+ return . client( client)
9231010 case . running( let client, _) :
9241011 return . client( client)
9251012 case . finishing, . finished:
@@ -935,16 +1022,18 @@ extension KafkaConsumer {
9351022 case triggerGracefulShutdown( client: RDKafkaClient )
9361023 }
9371024
938- /// Get action to be taken when wanting to do close the consumer.
939- /// - Returns: The action to be taken, or `nil` if there is no action to be taken.
940- ///
941- /// - Important: This function throws a `fatalError` if called while in the `.initializing` state.
1025+ /// Get action to be taken when wanting to close the consumer.
1026+ /// - Returns: The action to be taken, or `nil` if there is no action to be taken.
9421027 mutating func finish( ) -> FinishAction ? {
9431028 switch self . state {
9441029 case . uninitialized:
9451030 fatalError ( " \( #function) invoked while still in state \( self . state) " )
9461031 case . initializing:
947- fatalError ( " Subscribe to consumer group / assign to topic partition pair before reading messages " )
1032+ // No poll loop is active in .initializing state. Since consumerClose()
1033+ // requires an active poll loop to process broker responses, we skip it
1034+ // and transition straight to .finished.
1035+ self . state = . finished
1036+ return nil
9481037 case . running( let client, let rebalanceContext) :
9491038 self . state = . finishing( client: client, rebalanceContext: rebalanceContext)
9501039 return . triggerGracefulShutdown( client: client)
0 commit comments