Skip to content

Commit b4f129d

Browse files
authored
Add dynamic subscribe, unsubscribe APIs to KafkaConsumer (#227)
## Motivation Topics are locked at initialization time via `consumptionStrategy` with no way to change subscriptions at runtime. The existing private `subscribe()` crashes on re-subscribe (`fatalError`), and `consumptionStrategy` is force-unwrapped in `_run()` which crashes if nil. Applications that need dynamic topic routing, multi-tenant consumption, or config-driven subscriptions currently cannot use this client without recreating the consumer. This aligns the Swift client with the standard Kafka client contract where subscribe/unsubscribe are runtime operations, not initialization-time configuration. ## Summary - Add public `subscribe(topics:)`, `unsubscribe()`, and `subscription()` on `KafkaConsumer` - `subscribe(topics:)` replaces the current subscription, matching librdkafka semantics - Add `unsubscribe()` and `subscription()` to `RDKafkaClient` - Fix force-unwrap crash when `consumptionStrategy` is nil - Consumers can be created without `consumptionStrategy` and subscribe dynamically ## Test plan - [x] Unit tests: closed consumer, empty topics, subscribe before run, nil strategy, duplicate topics, regex patterns - [x] Integration tests: subscription replacement, unsubscribe, re-subscribe, dynamic multi-topic consume - [x] `swift test --skip IntegrationTests` passes - [x] `make docker-test` passes
1 parent 65b4de4 commit b4f129d

4 files changed

Lines changed: 680 additions & 36 deletions

File tree

Sources/Kafka/KafkaConsumer.swift

Lines changed: 110 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

Sources/Kafka/RDKafka/RDKafkaClient.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,36 @@ public final class RDKafkaClient: Sendable {
598598
}
599599
}
600600

601+
/// Unsubscribe from the current subscription set.
602+
func unsubscribe() throws {
603+
let result = rd_kafka_unsubscribe(self.kafkaHandle.pointer)
604+
if result != RD_KAFKA_RESP_ERR_NO_ERROR {
605+
throw KafkaError.rdKafkaError(wrapping: result)
606+
}
607+
}
608+
609+
/// Get the current topic subscription.
610+
/// - Returns: An array of topic names the consumer is subscribed to.
611+
func subscription() throws -> [String] {
612+
var tpl: UnsafeMutablePointer<rd_kafka_topic_partition_list_t>?
613+
let result = rd_kafka_subscription(self.kafkaHandle.pointer, &tpl)
614+
if result != RD_KAFKA_RESP_ERR_NO_ERROR {
615+
throw KafkaError.rdKafkaError(wrapping: result)
616+
}
617+
618+
guard let list = tpl else { return [] }
619+
defer { rd_kafka_topic_partition_list_destroy(list) }
620+
621+
var topics: [String] = []
622+
for i in 0..<Int(list.pointee.cnt) {
623+
let element = list.pointee.elems[i]
624+
if let topicCString = element.topic {
625+
topics.append(String(cString: topicCString))
626+
}
627+
}
628+
return topics
629+
}
630+
601631
/// Atomic assignment of partitions to consume.
602632
/// - Parameter topicPartitionList: Pointer to a list of topics + partition pairs.
603633
func assign(topicPartitionList: RDKafkaTopicPartitionList) throws {

0 commit comments

Comments
 (0)