Skip to main content

arminius_lib_nats/
consumergroup.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Consumer groups for NATS
6//!
7//! Allows creating Kafka consumer-group style sets of consumers for a JetStream
8//! stream. This class assumes that it has a monopoly on consuming the stream,
9//! i.e. that no other interface is used to consume it. Given a subject `subject`
10//! a group of [`ConsumerClient`]s will consume all messages on the subject
11//! `subject.[part].>` for every `part < partition_parts`. `partition_parts` is
12//! defined in the [`ConsumerClientConfig`]. When a set of ConsumerClients are
13//! consuming a stream, it is guaranteed that messages belonging to the same
14//! part will be treated in order. But message consumption is not guaranteed to
15//! be globally ordered, as more consumers running in parallel can outpace one
16//! another. A group of ConsumerClients will try their best to have the same
17//! parts allocated to the same ConsumerClient over time. However, when a new
18//! ConsumerClient goes online or offline, all ConsumerClients will briefly stop
19//! reading the event stream, and repartition the stream among them before they
20//! start reading messages again, with no regard to the previous distribution of
21//! parts in the partition.
22//!
23//! ## Example
24//! This example connects to a stream, that may or may not exist, and prints
25//! all messages received. The cool part is, you can run multiple instances of
26//! this program, and they'll distribute the work among themselves!
27//! ```no_run
28//! # use async_nats::jetstream;
29//! # use async_nats::jetstream::stream;
30//! # use async_nats::jetstream::stream::SubjectTransform;
31//! # use arminius_lib_nats::consumergroup;
32//! # use futures::StreamExt;
33//! # use std::process;
34//!
35//! #[tokio::main]
36//! async fn main() -> Result<(), async_nats::Error> {
37//! 	let client = async_nats::connect("localhost:4242").await?;
38//! 	let jetstream = jetstream::new(client.clone());
39//! 	let stream = jetstream
40//! 		.get_or_create_stream(stream::Config {
41//! 			name: "eventstream".to_owned(),
42//! 			subjects: vec!["events.>".to_owned()],
43//! 			retention: stream::RetentionPolicy::WorkQueue,
44//! 			subject_transform: Some(SubjectTransform {
45//! 				source: "events.*.>".to_owned(),
46//! 				destination: "events.{{partition(100, 1)}}.>".to_owned(),
47//! 			}),
48//! 			..Default::default()
49//! 		})
50//! 		.await?;
51//!
52//! 	let mut client = consumergroup::ConsumerClient::new(
53//! 		consumergroup::ConsumerClientConfig {
54//! 			consumer_name: format!("worker_{}", process::id()),
55//! 			subject: "events".to_owned(),
56//! 			num_parts: 100,
57//! 			..Default::default()
58//! 		},
59//! 		client,
60//! 		stream,
61//! 	)
62//! 	.await?;
63//!
64//! 	loop {
65//! 		if let Some(message) = client.get_message().await? {
66//! 			println!("Received message {:?}", message);
67//! 		}
68//! 	}
69//! 	Ok(())
70//! }
71//! ```
72
73mod tests;
74
75use std::time::{Duration, Instant};
76
77use async_nats::{
78	Message, Subscriber,
79	jetstream::{
80		consumer::{self, Consumer},
81		stream::{self, Stream},
82	},
83};
84use futures::{FutureExt as _, StreamExt as _, TryStreamExt as _};
85use rand::RngExt as _;
86use tokio::{runtime::Handle, task::block_in_place};
87
88use crate::client::room::ROOMSERVER_STREAM_PARTITION_PARTS;
89
90/// Configuration for a [ConsumerClient].
91#[derive(Debug, Clone)]
92pub struct ConsumerClientConfig {
93	/// Name of the consumer. Must be unique over the stream.
94	///
95	/// Defaults to a random number between `0` and `10000`.
96	pub consumer_name: String,
97	/// Subject to subscribe to.
98	///
99	/// Defaults to `"consumer_subject"`.
100	pub subject: String,
101	/// The number of parts the stream is partitioned into. This number should
102	/// be consistent with how the stream was partitioned, e.g with its
103	/// `subject_transform` option.
104	///
105	/// Defaults to `100`.
106	pub num_parts: usize,
107	/// How long the consumer should stay alive before JetStream kills it when
108	/// inactive.
109	///
110	/// Defaults to `30 seconds`.
111	pub inactive_threshold: Duration,
112	/// How many times the client should try updating the consumer's
113	/// `filter_subjects` before giving up. See module's documentation.
114	///
115	/// Defaults to `5`.
116	pub repartition_attempts: usize,
117	/// Time to wait between attempts to update the `filter_subjects`. See
118	/// module's documentation.
119	///
120	/// Defaults to `1 second`.
121	pub repartition_delay: Duration,
122	/// Timeout after which message fetching fails. If this timeout expires we
123	/// return `None`.
124	///
125	/// Defaults to `30 seconds`.
126	pub fetch_timeout: Duration,
127}
128
129impl Default for ConsumerClientConfig {
130	fn default() -> Self {
131		ConsumerClientConfig {
132			consumer_name: rand::rng().random_range(0..10000).to_string(),
133			subject: "consumer_subject".to_owned(),
134			num_parts: 100,
135			inactive_threshold: Duration::from_secs(30),
136			repartition_attempts: 30,
137			repartition_delay: Duration::from_secs(1),
138			fetch_timeout: Duration::from_secs(60),
139		}
140	}
141}
142
143impl ConsumerClientConfig {
144	/// Creates a consumer config that subscribes to the room subject
145	#[must_use]
146	pub fn for_rooms(name: String) -> Self {
147		Self {
148			consumer_name: name,
149			subject: "rooms".to_owned(),
150			num_parts: ROOMSERVER_STREAM_PARTITION_PARTS,
151			// A non-responsive consumer blocks a large part of the roomserver
152			// stream (depending on how many consumers we have). So I prefer having this number
153			// low, until we have performance reasons to increase it
154			inactive_threshold: Duration::from_secs(10),
155			..Default::default()
156		}
157	}
158}
159
160/// Updates a consumer to give it a subset of the parts in the stream partition,
161/// compared to all other consumers currently registered in the stream. If the
162/// consumer no longer exists, possibly as a result of the `inactive_threshold`,
163/// the consumer is recreated.
164#[tracing::instrument(skip(config, stream))]
165async fn update_or_recreate_consumer(
166	config: &ConsumerClientConfig,
167	num_partitions: usize,
168	stream: &Stream<stream::Info>,
169) -> Result<Consumer<consumer::pull::Config>, async_nats::Error> {
170	for _ in 0..config.repartition_attempts {
171		// In case our consumer died, we simply recreate it
172		let consumers: Vec<String> = stream.consumer_names().try_collect().await?;
173
174		tracing::trace!(
175			"{} trying to get filters with list of consumers: {:?}",
176			config.consumer_name,
177			consumers
178		);
179
180		let Some(worker_index) =
181			consumers.iter().position(|consumer| consumer == &config.consumer_name)
182		else {
183			tracing::trace!("Recreating decayed consumer {}", config.consumer_name);
184			_ = stream
185				.create_consumer(consumer::pull::Config {
186					name: Some(config.consumer_name.clone()),
187					filter_subjects: vec![format!("placeholder_filter.{}", config.consumer_name)],
188					inactive_threshold: config.inactive_threshold,
189					max_ack_pending: 1,
190					..Default::default()
191				})
192				.await?;
193			continue;
194		};
195
196		let num_workers = consumers.len();
197
198		let filter_subjects: Vec<String> = (0..num_partitions)
199			.skip(worker_index)
200			.step_by(num_workers)
201			.map(|i| format!("{}.{}.>", config.subject, i))
202			.collect();
203
204		if let Ok(consumer) = stream
205			.update_consumer(consumer::pull::Config {
206				name: Some(config.consumer_name.clone()),
207				// If we don't put anything, we subscribe to everything
208				filter_subjects: filter_subjects.clone(),
209				inactive_threshold: config.inactive_threshold,
210				max_ack_pending: 1,
211				..Default::default()
212			})
213			.await
214		{
215			tracing::trace!("{} successfully updated filter_subjects", config.consumer_name);
216
217			return Ok(consumer);
218		}
219
220		tokio::time::sleep(config.repartition_delay).await;
221	}
222
223	// If we can't update our consumer in a distributed manner, we don't claim any.
224	// This way, we don't disrupt any other consumers, since only one consumer can access any
225	// stream.
226	//
227	// This also means that the streams that this subscriber should have been subscribed to will be
228	// without a subscriber until it's recreated.
229	Ok(stream
230		.update_consumer(consumer::pull::Config {
231			name: Some(config.consumer_name.clone()),
232			filter_subjects: vec![format!("placeholder_filter.{}", config.consumer_name)],
233			inactive_threshold: config.inactive_threshold,
234			max_ack_pending: 1,
235			..Default::default()
236		})
237		.await?)
238}
239
240/// Handle controlling a single consumer.
241#[derive(Debug)]
242pub struct ConsumerClient {
243	/// Configuration used for setting up this consumer. See
244	/// [ConsumerClientConfig] for more info.
245	config: ConsumerClientConfig,
246	/// The stream over which the consumer was created.
247	stream: Stream<stream::Info>,
248	/// The actual `JetStream` consumer.
249	consumer: Consumer<consumer::pull::Config>,
250	/// The list of **other** consumers we currently know about.
251	consumer_list: Vec<String>,
252	/// When we last updated the list of consumers on the stream.
253	consumer_list_last_update: Instant,
254	/// A subscription to the subject in which new workers are announced.
255	new_worker_subscription: Subscriber,
256	/// A subscription to the subject in which deleted consumers are announced.
257	consumer_deleted_subscription: Subscriber,
258}
259
260/// Tries deleting the [ConsumerClient] from the NATS JetStream.
261impl Drop for ConsumerClient {
262	fn drop(&mut self) {
263		// TODO: Replace with AsyncDrop when stabilized
264		// Tracking issue: https://github.com/rust-lang/rust/issues/126482
265		block_in_place(move || {
266			Handle::current().block_on(async move {
267				if let Err(err) = self.stream.delete_consumer(&self.config.consumer_name).await {
268					tracing::warn!("Failed to delete consumer: {err}");
269				}
270			});
271		});
272
273		tracing::info!("Finished deleting consumer");
274	}
275}
276
277impl ConsumerClient {
278	/// Creates a new [ConsumerClient] over a stream.
279	pub async fn new(
280		config: ConsumerClientConfig,
281		client: async_nats::Client,
282		stream: Stream<stream::Info>,
283	) -> Result<Self, async_nats::Error> {
284		let stream_name = &stream.cached_info().config.name;
285		let new_worker_subscription = client
286			.subscribe(format!("$JS.EVENT.ADVISORY.CONSUMER.CREATED.{stream_name}.*"))
287			.await?;
288		let consumer_deleted_subscription = client
289			.subscribe(format!("$JS.EVENT.ADVISORY.CONSUMER.DELETED.{stream_name}.*"))
290			.await?;
291
292		let consumer = stream
293			.create_consumer_strict(consumer::pull::Config {
294				name: Some(config.consumer_name.clone()),
295				filter_subjects: vec![format!("placeholder_filter.{}", config.consumer_name)],
296				inactive_threshold: config.inactive_threshold,
297				max_ack_pending: 1,
298				..Default::default()
299			})
300			.await?;
301
302		Ok(ConsumerClient {
303			config,
304			stream,
305			consumer,
306			consumer_list: Vec::new(),
307			consumer_list_last_update: Instant::now() - Duration::from_secs(90),
308			new_worker_subscription,
309			consumer_deleted_subscription,
310		})
311	}
312
313	/// Updates the list of **other** consumers we know are connected on the
314	/// stream.
315	async fn update_consumer_list(&mut self) -> Result<(), async_nats::Error> {
316		self.consumer_list = self.stream.consumer_names().try_collect().await?;
317		self.consumer_list_last_update = Instant::now();
318
319		Ok(())
320	}
321
322	/// Updates the list of subjects we are consuming.
323	#[tracing::instrument(skip(self))]
324	async fn update_filter_subjects(&mut self) -> Result<(), async_nats::Error> {
325		tracing::trace!("{}: Rebuilding consumer", self.config.consumer_name);
326		match self
327			.stream
328			.update_consumer(consumer::pull::Config {
329				name: Some(self.config.consumer_name.clone()),
330				filter_subjects: vec![format!("placeholder_filter.{}", self.config.consumer_name)],
331				inactive_threshold: self.config.inactive_threshold,
332				max_ack_pending: 1,
333				..Default::default()
334			})
335			.await
336		{
337			Ok(_) => Ok(()),
338			Err(err) => match err.kind() {
339				// We want to ignore this error
340				stream::ConsumerUpdateErrorKind::DoesNotExist => Ok(()),
341				_ => Err(err),
342			},
343		}?;
344
345		self.consumer =
346			update_or_recreate_consumer(&self.config, self.config.num_parts, &self.stream).await?;
347
348		self.update_consumer_list().await?;
349
350		Ok(())
351	}
352
353	/// Handles a notification for a new consumer arriving,
354	/// possibly triggering a re-distribution of the stream's partition.
355	#[tracing::instrument(skip(self))]
356	async fn handle_new_worker_message(
357		&mut self,
358		msg: Option<Message>,
359	) -> Result<(), async_nats::Error> {
360		if let Some(msg) = msg {
361			let pending_worker = msg.subject.rsplit(".").next().unwrap_or("UNKNOWN");
362			if !self.consumer_list.iter().any(|w| w == pending_worker) {
363				tracing::trace!("{}: New worker {}!", self.config.consumer_name, pending_worker);
364				self.update_filter_subjects().await?;
365			}
366		}
367		Ok(())
368	}
369
370	/// Handles a notification for a consumer on the stream going offline,
371	/// possibly triggering a re-distribution of the stream partition.
372	#[tracing::instrument(skip(self))]
373	async fn handle_worker_deleted_message(
374		&mut self,
375		msg: Option<Message>,
376	) -> Result<(), async_nats::Error> {
377		if let Some(msg) = msg {
378			// We can just ignore unknown workers going offline
379			let offline_worker = msg.subject.rsplit(".").next().unwrap_or("UNKNOWN");
380			tracing::trace!("{}: {} went offline!", self.config.consumer_name, offline_worker);
381			if self.consumer_list.iter().any(|w| w == offline_worker) {
382				self.update_filter_subjects().await?;
383			}
384		}
385		Ok(())
386	}
387
388	/// Makes sure that the distribution of rooms between [ConsumerClient]s is
389	/// correct, and then tries to fetch the next message
390	///
391	/// ## Returns
392	///
393	/// Returns the Message. If it returns `None`, this means
394	/// it has reached `fetch_timeout` without a message being received
395	#[tracing::instrument(skip(self))]
396	pub async fn get_message(
397		&mut self,
398	) -> Result<Option<async_nats::jetstream::Message>, async_nats::Error> {
399		// Update distribution of rooms until it stabilizes
400		loop {
401			if self.consumer_list_last_update < Instant::now() - Duration::from_secs(90) {
402				tracing::trace!("Rebuilding consumer due to timeout");
403				self.update_filter_subjects().await?;
404				continue;
405			}
406
407			if let Some(msg) = self.new_worker_subscription.next().now_or_never() {
408				self.handle_new_worker_message(msg).await?;
409				continue;
410			}
411			if let Some(msg) = self.consumer_deleted_subscription.next().now_or_never() {
412				self.handle_worker_deleted_message(msg).await?;
413				continue;
414			}
415
416			break;
417		}
418
419		// And then we try to fetch a message
420		let consumer_clone = self.consumer.clone();
421		consumer_clone
422			.fetch()
423			.max_messages(1)
424			.expires(self.config.fetch_timeout)
425			.messages()
426			.await?
427			.try_next()
428			.await
429	}
430}