1use crate::error::LaserError;
2use crate::laser::Laser;
3use crate::message::Message;
4use crate::types::MessageId;
5use iggy::prelude::*;
6use std::collections::BTreeMap;
7
8const DEFAULT_BATCH: u32 = 1000;
9
10impl Laser {
11 #[cfg(feature = "agent")]
26 pub(crate) fn reader(&self, topic: impl Into<String>) -> Result<Cursor, LaserError> {
27 let stream = self.stream_required()?.to_owned();
28 Cursor::new(self, &stream, &topic.into())
29 }
30
31 pub(crate) fn reader_on(
34 &self,
35 stream: impl Into<String>,
36 topic: impl Into<String>,
37 ) -> Result<Cursor, LaserError> {
38 Cursor::new(self, &stream.into(), &topic.into())
39 }
40}
41
42pub struct Cursor {
48 laser: Laser,
49 stream: Identifier,
50 topic: Identifier,
51 consumer: Consumer,
52 offsets: Vec<u64>,
53 batch: u32,
54}
55
56impl Cursor {
57 fn new(laser: &Laser, stream: &str, topic: &str) -> Result<Self, LaserError> {
58 Ok(Self {
59 stream: Identifier::named(stream)?,
60 topic: Identifier::named(topic)?,
61 consumer: Consumer::new(Identifier::named("laser-cursor")?),
62 laser: laser.clone(),
63 offsets: Vec::new(),
64 batch: DEFAULT_BATCH,
65 })
66 }
67
68 pub fn from_offsets(mut self, offsets: Vec<u64>) -> Self {
73 self.offsets = offsets;
74 self
75 }
76
77 pub fn batch(mut self, batch: u32) -> Self {
84 self.batch = batch.max(1);
85 self
86 }
87
88 pub(crate) fn consumer_named(mut self, name: &str) -> Result<Self, LaserError> {
92 self.consumer = Consumer::new(Identifier::named(name)?);
93 Ok(self)
94 }
95
96 pub fn offsets(&self) -> &[u64] {
99 &self.offsets
100 }
101
102 pub fn stream(self) -> impl futures::Stream<Item = Result<Message, LaserError>> {
110 futures::stream::unfold(
111 (self, std::collections::VecDeque::new(), false),
112 |(mut cursor, mut buffered, stop)| async move {
113 if stop {
114 return None;
115 }
116 loop {
117 if let Some(message) = buffered.pop_front() {
118 return Some((Ok(message), (cursor, buffered, false)));
119 }
120 match cursor.poll().await {
121 Ok(batch) if batch.is_empty() => return None,
122 Ok(batch) => buffered.extend(batch),
123 Err(error) => return Some((Err(error), (cursor, buffered, true))),
124 }
125 }
126 },
127 )
128 }
129
130 #[tracing::instrument(target = "laser", level = "debug", skip_all, fields(topic = %self.topic, operation = "poll"))]
134 pub async fn poll(&mut self) -> Result<Vec<Message>, LaserError> {
135 Ok(self
136 .poll_iggy()
137 .await?
138 .into_iter()
139 .map(|(partition, message)| Message {
140 id: MessageId::new(partition, message.header.offset),
141 payload: message.payload.to_vec(),
142 headers: headers_to_strings(&message),
143 })
144 .collect())
145 }
146
147 pub(crate) async fn poll_iggy(&mut self) -> Result<Vec<(u32, IggyMessage)>, LaserError> {
148 let Some(details) = self
149 .laser
150 .client()
151 .get_topic(&self.stream, &self.topic)
152 .await?
153 else {
154 return Ok(Vec::new());
155 };
156 let partitions = crate::poll::bounded_partitions(details.partitions_count) as usize;
157 let mut next_offsets = self.offsets.clone();
158 if next_offsets.len() < partitions {
159 next_offsets.resize(partitions, 0);
160 }
161 let mut collected: Vec<(u64, u32, u64, IggyMessage)> = Vec::new();
164 for partition in 0..partitions as u32 {
165 let batch = crate::poll::drain_partition(
166 &self.laser.client(),
167 &self.stream,
168 &self.topic,
169 &self.consumer,
170 crate::poll::DrainRange::open(partition, next_offsets[partition as usize]),
171 self.batch,
172 )
173 .await?;
174 next_offsets[partition as usize] = batch.next_offset;
175 for message in batch.messages {
176 let offset = message.header.offset;
177 collected.push((message.header.timestamp, partition, offset, message));
178 }
179 }
180 collected
181 .sort_by_key(|(timestamp, partition, offset, _)| (*timestamp, *partition, *offset));
182 self.offsets = next_offsets;
183 Ok(collected
184 .into_iter()
185 .map(|(_, partition, _, message)| (partition, message))
186 .collect())
187 }
188}
189
190fn headers_to_strings(message: &IggyMessage) -> BTreeMap<String, String> {
193 let Ok(Some(headers)) = message.user_headers_map() else {
194 return BTreeMap::new();
195 };
196 headers
197 .iter()
198 .filter_map(|(key, value)| {
199 Some((
200 key.as_str().ok()?.to_owned(),
201 value.as_str().ok()?.to_owned(),
202 ))
203 })
204 .collect()
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use axum::extract::{Query, State};
211 use axum::http::StatusCode;
212 use axum::response::{IntoResponse, Response};
213 use axum::routing::get;
214 use axum::{Json, Router};
215 use iggy::http::http_client::HttpClient;
216 use std::collections::HashMap;
217 use std::sync::Arc;
218 use std::sync::atomic::{AtomicBool, Ordering};
219 use tokio::net::TcpListener;
220 use tokio::sync::Notify;
221 use tokio::task::JoinHandle;
222
223 #[tokio::test]
224 async fn given_later_partition_failure_when_polling_then_should_preserve_all_offsets() {
225 let server = TestServer::start(2, 2).await;
226 server.state.fail.store(true, Ordering::SeqCst);
227 let mut cursor = server.cursor().from_offsets(vec![0, 0]);
228
229 assert!(cursor.poll().await.is_err());
230 assert_eq!(cursor.offsets(), &[0, 0]);
231
232 let messages = cursor.poll().await.expect("retry all partitions");
233 assert_eq!(messages.len(), 4);
234 assert_eq!(cursor.offsets(), &[2, 2]);
235 }
236
237 #[tokio::test]
238 async fn given_later_partition_wait_when_poll_is_cancelled_then_should_preserve_all_offsets() {
239 let server = TestServer::start(2, 2).await;
240 server.state.block.store(true, Ordering::SeqCst);
241 let mut cursor = server.cursor().from_offsets(vec![0, 0]);
242
243 tokio::select! {
244 result = cursor.poll() => panic!("poll must wait for the second partition: {result:?}"),
245 () = server.state.waiting.notified() => {}
246 }
247 assert_eq!(cursor.offsets(), &[0, 0]);
248
249 server.state.block.store(false, Ordering::SeqCst);
250 server.state.release.notify_one();
251 let messages = cursor.poll().await.expect("retry cancelled poll");
252 assert_eq!(messages.len(), 4);
253 assert_eq!(cursor.offsets(), &[2, 2]);
254 }
255
256 #[tokio::test]
257 async fn given_large_batches_when_polling_then_should_bound_reads_and_resume_without_gaps() {
258 let limit = crate::poll::MAX_DRAIN_MESSAGES;
259 let server = TestServer::start(1, limit as u64 + 1).await;
260 for batch in [6000, 20000] {
261 let mut cursor = server.cursor().batch(batch);
262 let messages = cursor.poll().await.expect("bounded read");
263 assert_eq!(messages.len(), limit, "batch size {batch}");
264 assert_eq!(cursor.offsets(), &[limit as u64]);
265
266 let remaining = cursor.poll().await.expect("resume after limit");
267 assert_eq!(remaining.len(), 1);
268 assert_eq!(remaining[0].id, MessageId::new(0, limit as u64));
269 assert_eq!(cursor.offsets(), &[limit as u64 + 1]);
270 }
271 }
272
273 struct TestServer {
274 url: String,
275 state: Arc<TestState>,
276 task: JoinHandle<()>,
277 }
278
279 struct TestState {
280 partitions: u32,
281 messages: u64,
282 fail: AtomicBool,
283 block: AtomicBool,
284 waiting: Notify,
285 release: Notify,
286 }
287
288 impl TestServer {
289 async fn start(partitions: u32, messages: u64) -> Self {
290 let state = Arc::new(TestState {
291 partitions,
292 messages,
293 fail: AtomicBool::new(false),
294 block: AtomicBool::new(false),
295 waiting: Notify::new(),
296 release: Notify::new(),
297 });
298 let app = Router::new()
299 .route("/streams/test/topics/events", get(topic))
300 .route("/streams/test/topics/events/messages", get(poll))
301 .with_state(state.clone());
302 let listener = TcpListener::bind("127.0.0.1:0")
303 .await
304 .expect("test listener");
305 let url = format!(
306 "http://{}",
307 listener.local_addr().expect("listener address")
308 );
309 let task = tokio::spawn(async move {
310 axum::serve(listener, app).await.expect("test server");
311 });
312 Self { url, state, task }
313 }
314
315 fn cursor(&self) -> Cursor {
316 let client = HttpClient::create(Arc::new(HttpClientConfig {
317 api_url: self.url.clone(),
318 jwt: Some("test-token".to_owned()),
319 retries: 0,
320 ..Default::default()
321 }))
322 .expect("HTTP client");
323 Laser::from_client(IggyClient::new(ClientWrapper::Http(client)))
324 .stream("test")
325 .topic("events")
326 .replay()
327 .expect("cursor")
328 }
329 }
330
331 impl Drop for TestServer {
332 fn drop(&mut self) {
333 self.task.abort();
334 }
335 }
336
337 async fn topic(State(state): State<Arc<TestState>>) -> Json<TopicDetails> {
338 Json(TopicDetails {
339 id: 1,
340 created_at: IggyTimestamp::from(0),
341 name: "events".to_owned(),
342 size: IggyByteSize::from(0),
343 message_expiry: IggyExpiry::NeverExpire,
344 compression_algorithm: CompressionAlgorithm::None,
345 max_topic_size: MaxTopicSize::ServerDefault,
346 messages_count: state.messages * u64::from(state.partitions),
347 partitions_count: state.partitions,
348 partitions: Vec::new(),
349 options: Default::default(),
350 })
351 }
352
353 async fn poll(
354 State(state): State<Arc<TestState>>,
355 Query(query): Query<HashMap<String, String>>,
356 ) -> Response {
357 let partition = query["partition_id"].parse().expect("partition id");
358 if partition == 1 {
359 if state.fail.swap(false, Ordering::SeqCst) {
360 return (StatusCode::BAD_REQUEST, "invalid poll").into_response();
361 }
362 if state.block.load(Ordering::SeqCst) {
363 state.waiting.notify_one();
364 state.release.notified().await;
365 }
366 }
367 let offset: u64 = query["value"].parse().expect("poll offset");
368 let count: u64 = query["count"].parse().expect("poll count");
369 let messages: Vec<_> = (offset..(offset + count).min(state.messages))
370 .map(|offset| {
371 let mut message = IggyMessage::builder()
372 .payload(vec![1].into())
373 .build()
374 .expect("test message");
375 message.header.offset = offset;
376 message.header.timestamp = offset;
377 message
378 })
379 .collect();
380 Json(PolledMessages {
381 partition_id: partition,
382 current_offset: state.messages.saturating_sub(1),
383 count: messages.len() as u32,
384 messages,
385 })
386 .into_response()
387 }
388}