1use crate::drains::api::Drain;
21use crate::log_group::LogGroup;
22use crate::record::Record;
23use chrono::{DateTime, Utc};
24use uuid::Uuid; #[derive(Debug, Clone)]
35pub struct LogStore<D: Drain> {
36 drain: D,
37}
38
39impl<D: Drain> LogStore<D> {
40 pub fn new(drain: D) -> Self {
51 Self { drain }
52 }
53
54 pub fn get_log_group_by_id(&self, id: Uuid) -> Option<LogGroup> {
68 self.drain
69 .collect_log_groups()
70 .into_iter()
71 .find(|lg| lg.id == id)
72 }
73
74 pub fn get_log_groups_in_range(
89 &self,
90 start_time: DateTime<Utc>,
91 end_time: DateTime<Utc>,
92 ) -> Vec<LogGroup> {
93 self.drain
94 .collect_log_groups()
95 .into_iter()
96 .filter(|log_group| {
97 let timestamp = log_group.get_time();
98 timestamp >= start_time && timestamp <= end_time
99 })
100 .collect()
101 }
102}
103
104#[derive(Debug, Clone)]
107pub enum StreamSelector {
108 LogGroupIds(Vec<Uuid>),
110}
111
112#[derive(Debug, Clone)]
114pub struct LineFilter {
115 pub contains: String,
117}
118
119#[derive(Debug, Clone)]
121pub struct LogQlQuery {
122 pub selector: StreamSelector,
124 pub filter: Option<LineFilter>,
126}
127
128#[derive(Debug, Clone)]
130pub enum QuerySource {
131 ById(Uuid),
133 ByLogQl(LogQlQuery),
135}
136
137pub fn execute_logql_query<D: Drain>(log_store: &LogStore<D>, query: &LogQlQuery) -> Vec<Record> {
155 let mut records_batch: Vec<Record> = Vec::new();
156
157 let collected_groups = log_store.drain.collect_log_groups();
158
159 match &query.selector {
160 StreamSelector::LogGroupIds(group_ids) => {
161 for group_id in group_ids {
162 if let Some(log_group) = collected_groups.iter().find(|lg| lg.id == *group_id) {
163 records_batch.push(log_group.base_record().clone()); records_batch.extend(log_group.examples().iter().cloned()); }
166 }
167 }
168 }
169
170 if let Some(filter) = &query.filter {
171 records_batch.retain(|record| record.to_string().contains(&filter.contains));
172 }
173
174 records_batch
175}
176
177pub fn query_log_range_aggregation<D: Drain>(
197 log_store: &LogStore<D>,
198 query_source: QuerySource,
199 start_time: DateTime<Utc>,
200 end_time: DateTime<Utc>,
201) -> Vec<Record> {
202 let initial_records: Vec<Record> = match query_source {
203 QuerySource::ById(group_id) => {
204 log_store.get_log_group_by_id(group_id).map_or_else(
205 Vec::new, |log_group| {
207 let mut records = vec![log_group.base_record().clone()];
209 records.extend(log_group.examples().iter().cloned());
210 records
211 },
212 )
213 }
214 QuerySource::ByLogQl(logql_query) => {
215 execute_logql_query(log_store, &logql_query) }
217 };
218
219 initial_records
220 .into_iter()
221 .filter(|record| {
222 record.uid.get_timestamp().is_some_and(|ts| {
224 let (secs_u64, nanos) = ts.to_unix();
225 let secs_i64 = secs_u64 as i64;
226 if let Some(timestamp) = DateTime::from_timestamp(secs_i64, nanos) {
227 timestamp >= start_time && timestamp <= end_time
228 } else {
229 false
230 }
231 })
232 })
233 .collect()
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use chrono::Duration as ChronoDuration;
240 use chrono::Utc; use proptest::collection::vec as prop_vec; use proptest::prelude::*; use proptest::sample::subsequence; use std::collections::{HashMap, HashSet}; use std::thread::sleep;
246 use std::time::Duration as StdDuration;
247 use uuid::{Uuid, Version}; fn get_record_timestamp(record: &Record) -> Option<DateTime<Utc>> {
252 record.uid.get_timestamp().and_then(|ts| {
253 let (secs_u64, nanos) = ts.to_unix();
254 let secs_i64 = secs_u64 as i64;
255 DateTime::from_timestamp(secs_i64, nanos)
256 })
257 }
258
259 fn arb_record_content() -> impl Strategy<Value = String> {
263 prop_vec(r"[a-zA-Z0-9]+", 1..10usize) .prop_map(|words| words.join(" "))
265 }
266
267 fn arb_record() -> impl Strategy<Value = Record> {
269 arb_record_content().prop_map(Record::new)
270 }
271
272 fn arb_line_filter() -> impl Strategy<Value = LineFilter> {
274 "[a-zA-Z0-9]{1,10}".prop_map(|s| LineFilter { contains: s })
275 }
276
277 fn arb_optional_line_filter() -> impl Strategy<Value = Option<LineFilter>> {
279 prop_oneof![Just(None), arb_line_filter().prop_map(Some),]
280 }
281
282 fn arb_datetime_utc() -> impl Strategy<Value = DateTime<Utc>> {
285 (0i64..3600 * 24 * 30) .prop_map(|offset_secs| Utc::now() - ChronoDuration::seconds(offset_secs))
287 }
288
289 fn arb_log_group() -> impl Strategy<Value = LogGroup> {
291 (arb_record(), prop_vec(arb_record(), 0..5usize)).prop_map(
292 |(base_record, example_records)| {
293 let mut group = LogGroup::new(base_record);
294 for rec in example_records {
295 group.add_example(rec);
296 }
297 group
298 },
299 )
300 }
301
302 fn arb_log_store_data() -> impl Strategy<Value = (Vec<LogGroup>, Vec<Uuid>)> {
304 prop_vec(arb_log_group(), 0..10usize) .prop_map(|groups| {
306 let group_ids = groups.iter().map(|g| g.id).collect::<Vec<Uuid>>();
307 (groups, group_ids)
308 })
309 }
310
311 fn arb_stream_selector(valid_ids: Vec<Uuid>) -> impl Strategy<Value = StreamSelector> {
313 if valid_ids.is_empty() {
314 return Just(StreamSelector::LogGroupIds(Vec::new())).boxed();
315 }
316 let id_subset_strategy = subsequence(valid_ids.clone(), 0..valid_ids.len())
318 .prop_map(StreamSelector::LogGroupIds);
319
320 let random_ids_strategy = prop_vec(Just(Uuid::new_v4()), 0..3usize) .prop_map(StreamSelector::LogGroupIds);
323
324 prop_oneof![
325 id_subset_strategy,
326 random_ids_strategy,
327 (
329 subsequence(valid_ids.clone(), 0..valid_ids.len()),
330 prop_vec(Just(Uuid::new_v4()), 0..2usize)
331 )
332 .prop_map(|(mut subset, mut random_guids)| {
333 subset.append(&mut random_guids);
334 StreamSelector::LogGroupIds(subset)
335 })
336 ]
337 .boxed()
338 }
339
340 fn arb_logql_query(valid_ids: Vec<Uuid>) -> impl Strategy<Value = LogQlQuery> {
342 (arb_stream_selector(valid_ids), arb_optional_line_filter())
343 .prop_map(|(selector, filter)| LogQlQuery { selector, filter })
344 }
345
346 fn arb_log_groups_and_query() -> impl Strategy<Value = (Vec<LogGroup>, LogQlQuery)> {
348 arb_log_store_data()
349 .prop_flat_map(|(groups, group_ids)| (Just(groups), arb_logql_query(group_ids)))
350 }
351
352 fn arb_log_groups_query_and_times(
354 ) -> impl Strategy<Value = (Vec<LogGroup>, LogQlQuery, DateTime<Utc>, DateTime<Utc>)> {
355 arb_log_store_data().prop_flat_map(|(groups, group_ids)| {
356 (
357 Just(groups),
358 arb_logql_query(group_ids),
359 arb_datetime_utc(),
360 arb_datetime_utc(),
361 )
362 })
363 }
364
365 #[test]
366 fn test_record_uuid_timestamp_extraction() {
367 let record = Record::new("Test record for UUID and timestamp".to_string());
368 assert_eq!(record.uid.get_version(), Some(Version::Mac)); let timestamp = get_record_timestamp(&record);
371 assert!(
372 timestamp.is_some(),
373 "Timestamp should be extractable from record UID"
374 );
375 }
376
377 #[test]
378 fn test_log_group_timestamp_extraction() {
379 let now = Utc::now();
380 sleep(StdDuration::from_millis(10)); let record = Record::new("Test record for LogGroup timestamp".to_string());
382 let log_group = LogGroup::new(record);
383 sleep(StdDuration::from_millis(10)); let after_creation = Utc::now();
385
386 let group_time = log_group.get_time();
387
388 assert!(
391 group_time > now && group_time < after_creation,
392 "LogGroup time {:?} should be between {:?} and {:?}",
393 group_time,
394 now,
395 after_creation
396 );
397 assert!(
399 (Utc::now() - group_time).num_seconds() < 5,
400 "LogGroup time should be recent"
401 );
402 }
403
404 #[derive(Clone)] struct MockDrain {
407 groups: HashMap<Uuid, LogGroup>,
408 }
409
410 impl MockDrain {
411 fn new() -> Self {
412 Self {
413 groups: HashMap::new(),
414 }
415 }
416
417 #[allow(dead_code)] fn add_group(&mut self, group: LogGroup) {
419 self.groups.insert(group.id, group);
420 }
421 }
422
423 impl Drain for MockDrain {
424 fn process_line(&mut self, _line: String) -> anyhow::Result<bool> {
425 Ok(false)
427 }
428
429 fn collect_log_groups(&self) -> Vec<LogGroup> {
430 self.groups.values().cloned().collect()
431 }
432 }
433
434 #[test]
435 fn test_log_store_new() {
436 let mock_drain = MockDrain::new();
437 let store = LogStore::new(mock_drain);
438 assert!(
443 store
444 .get_log_groups_in_range(Utc::now(), Utc::now())
445 .is_empty(),
446 "New LogStore with empty drain should have no groups in range"
447 );
448 }
449
450 #[test]
451 fn test_log_store_get_by_id() {
452 let record1 = Record::new("Record 1 for get_by_id".to_string());
453 let group1 = LogGroup::new(record1);
454 let id1 = group1.id;
455
456 let mut mock_drain = MockDrain::new();
457 mock_drain.add_group(group1.clone()); let store = LogStore::new(mock_drain);
459
460 let found_group = store.get_log_group_by_id(id1);
461 assert!(found_group.is_some(), "Should find existing group by ID");
462 assert_eq!(found_group.unwrap().id, id1);
463
464 let non_existent_id = Uuid::new_v4();
465 let not_found_group = store.get_log_group_by_id(non_existent_id);
466 assert!(
467 not_found_group.is_none(),
468 "Should return None for non-existent group ID"
469 );
470 }
471
472 #[test]
473 fn test_log_store_get_in_range() {
474 let mut mock_drain = MockDrain::new();
475 let base_time = Utc::now();
476
477 let r1 = Record::new("g1".to_string());
478 let g1 = LogGroup::new(r1);
479 let time1 = g1.get_time();
480 mock_drain.add_group(g1.clone());
481 sleep(StdDuration::from_millis(100)); let r2 = Record::new("g2".to_string());
484 let g2 = LogGroup::new(r2);
485 let time2 = g2.get_time();
486 mock_drain.add_group(g2.clone());
487 sleep(StdDuration::from_millis(100));
488
489 let r3 = Record::new("g3".to_string());
490 let g3 = LogGroup::new(r3);
491 let time3 = g3.get_time();
492 mock_drain.add_group(g3.clone());
493
494 let store = LogStore::new(mock_drain);
495
496 assert!(time1 < time2, "time1 should be less than time2");
497 assert!(time2 < time3, "time2 should be less than time3");
498
499 let all_groups = store.get_log_groups_in_range(time1, time3);
500 assert_eq!(
501 all_groups.len(),
502 3,
503 "Should find all 3 groups. Found: {:?}",
504 all_groups.iter().map(|g| g.id).collect::<Vec<_>>()
505 );
506
507 let some_groups_middle = store.get_log_groups_in_range(
508 time1 + ChronoDuration::milliseconds(50),
509 time3 - ChronoDuration::milliseconds(50),
510 );
511 assert_eq!(some_groups_middle.len(), 1, "Should find 1 group (g2)");
512 assert_eq!(some_groups_middle[0].id, g2.id);
513
514 let some_groups_first_two = store.get_log_groups_in_range(time1, time2);
515 assert_eq!(
516 some_groups_first_two.len(),
517 2,
518 "Should find 2 groups (g1, g2)"
519 );
520
521 let no_groups_before = store.get_log_groups_in_range(
522 base_time - ChronoDuration::seconds(10),
523 base_time - ChronoDuration::seconds(5),
524 );
525 assert_eq!(
526 no_groups_before.len(),
527 0,
528 "Should find no groups (range before all)"
529 );
530
531 let no_groups_after = store.get_log_groups_in_range(
532 time3 + ChronoDuration::seconds(5),
533 time3 + ChronoDuration::seconds(10),
534 );
535 assert_eq!(
536 no_groups_after.len(),
537 0,
538 "Should find no groups (range after all)"
539 );
540
541 let exact_match_g2 = store.get_log_groups_in_range(time2, time2);
542 assert_eq!(
543 exact_match_g2.len(),
544 1,
545 "Should find g2 with exact time match"
546 );
547 assert_eq!(exact_match_g2[0].id, g2.id);
548
549 let edge_start = store.get_log_groups_in_range(time1, time1);
550 assert_eq!(
551 edge_start.len(),
552 1,
553 "Should find g1 when it's exactly on start_time"
554 );
555 assert_eq!(edge_start[0].id, g1.id);
556
557 let edge_end = store.get_log_groups_in_range(time3, time3);
558 assert_eq!(
559 edge_end.len(),
560 1,
561 "Should find g3 when it's exactly on end_time"
562 );
563 assert_eq!(edge_end[0].id, g3.id);
564 }
565
566 #[test]
567 fn test_query_log_range_aggregation() {
568 let mut mock_drain = MockDrain::new();
569 let record_template = Record::new("Log group for aggregation test".to_string());
570 let mut log_group = LogGroup::new(record_template);
571 let group_id = log_group.id;
572 let base_time = Utc::now();
573
574 let ex_r1 = Record::new("Example 1".to_string());
575 let time1 = get_record_timestamp(&ex_r1).unwrap();
576 log_group.add_example(ex_r1);
577 sleep(StdDuration::from_millis(50));
578
579 let ex_r2 = Record::new("Example 2".to_string());
580 let time2 = get_record_timestamp(&ex_r2).unwrap();
581 log_group.add_example(ex_r2);
582 sleep(StdDuration::from_millis(50));
583
584 let ex_r3 = Record::new("Example 3".to_string());
585 let time3 = get_record_timestamp(&ex_r3).unwrap();
586 log_group.add_example(ex_r3);
587
588 mock_drain.add_group(log_group);
589 let store = LogStore::new(mock_drain);
590
591 let non_existent_id = Uuid::new_v4();
592 let results_not_found = query_log_range_aggregation(
593 &store, QuerySource::ById(non_existent_id),
595 base_time, Utc::now(), );
598 assert!(
599 results_not_found.is_empty(),
600 "Should return empty for non-existent group ID"
601 );
602
603 let results_none_in_range = query_log_range_aggregation(
604 &store,
605 QuerySource::ById(group_id),
606 base_time - ChronoDuration::seconds(10),
607 base_time - ChronoDuration::seconds(5),
608 );
609 assert!(
610 results_none_in_range.is_empty(),
611 "Should return empty if no records in time range"
612 );
613
614 let results_some_in_range = query_log_range_aggregation(
615 &store,
616 QuerySource::ById(group_id),
617 time1 + ChronoDuration::milliseconds(25), time3 - ChronoDuration::milliseconds(25), );
620 assert_eq!(
621 results_some_in_range.len(),
622 1,
623 "Should find 1 record in the middle of the range"
624 );
625 assert_eq!(
626 get_record_timestamp(&results_some_in_range[0]).unwrap(), time2
628 );
629
630 let results_all_in_range =
631 query_log_range_aggregation(&store, QuerySource::ById(group_id), time1, time3);
632 assert_eq!(
633 results_all_in_range.len(),
634 3,
635 "Should find all 3 records in the range"
636 );
637
638 let results_edge_start =
639 query_log_range_aggregation(&store, QuerySource::ById(group_id), time1, time1);
640 assert_eq!(
641 results_edge_start.len(),
642 1,
643 "Should find record 1 when it is exactly on start_time"
644 );
645 assert_eq!(get_record_timestamp(&results_edge_start[0]).unwrap(), time1); let results_edge_end =
648 query_log_range_aggregation(&store, QuerySource::ById(group_id), time3, time3);
649 assert_eq!(
650 results_edge_end.len(),
651 1,
652 "Should find record 3 when it is exactly on end_time"
653 );
654 assert_eq!(get_record_timestamp(&results_edge_end[0]).unwrap(), time3); let results_first_two =
657 query_log_range_aggregation(&store, QuerySource::ById(group_id), time1, time2);
658 assert_eq!(results_first_two.len(), 2, "Should find first two records");
659 }
660
661 fn log_store_from_mock_groups(groups: Vec<LogGroup>) -> LogStore<MockDrain> {
664 let mut drain = MockDrain::new();
665 for group in groups {
666 drain.add_group(group);
667 }
668 LogStore::new(drain)
669 }
670
671 proptest! {
672 #[test]
673 fn prop_execute_logql_query(
674 (log_groups_vec, query) in arb_log_groups_and_query()
675 ) {
676 let log_store = log_store_from_mock_groups(log_groups_vec.clone());
678 let results = execute_logql_query(&log_store, &query);
679
680 let selected_group_ids_set: HashSet<Uuid> = match &query.selector {
681 StreamSelector::LogGroupIds(ids) => ids.iter().cloned().collect(),
682 };
683
684 for record_in_result in &results {
685 let mut record_belongs_to_a_selected_group = false;
686 for group in &log_groups_vec { if selected_group_ids_set.contains(&group.id) && (group.base_record().uid == record_in_result.uid || group.examples().iter().any(|ex| ex.uid == record_in_result.uid)) {
688 record_belongs_to_a_selected_group = true;
689 break;
690 }
691 }
692 prop_assert!(record_belongs_to_a_selected_group, "Record {} from results (content: '{}') does not belong to any selected group. Selected groups: {:?}", record_in_result.uid, record_in_result.to_string(), selected_group_ids_set);
693
694 if let Some(filter) = &query.filter {
695 prop_assert!(record_in_result.to_string().contains(&filter.contains),
696 "Record {} from results (content: '{}') does not match filter '{}'", record_in_result.uid, record_in_result.to_string(), filter.contains);
697 }
698 }
699
700 for group in &log_groups_vec { if selected_group_ids_set.contains(&group.id) {
702 let records_to_check = group.examples().clone();
703 for original_record in records_to_check {
704 let matches_filter = query.filter.as_ref().is_none_or(|f| original_record.to_string().contains(&f.contains));
705 if matches_filter {
706 prop_assert!(results.iter().any(|res_rec| res_rec.uid == original_record.uid),
707 "Original record {} (content: '{}') from group {} matches filter but not found in results. Filter: {:?}", original_record.uid, original_record.to_string(), group.id, query.filter.as_ref().map(|f| &f.contains));
708 } else {
709 prop_assert!(!results.iter().any(|res_rec| res_rec.uid == original_record.uid),
710 "Original record {} (content: '{}') from group {} does NOT match filter but IS found in results. Filter: {:?}", original_record.uid, original_record.to_string(), group.id, query.filter.as_ref().map(|f| &f.contains));
711 }
712 }
713 }
714 }
715 }
716 }
717
718 proptest! {
719 #[test]
720 fn prop_query_log_range_aggregation_with_logql(
721 (log_groups_vec, query, time1, time2) in arb_log_groups_query_and_times()
722 ) {
723 let log_store = log_store_from_mock_groups(log_groups_vec.clone()); let (start_time, end_time) = if time1 <= time2 { (time1, time2) } else { (time2, time1) };
725 let results = query_log_range_aggregation(&log_store, QuerySource::ByLogQl(query.clone()), start_time, end_time);
726
727 let selected_group_ids_set: HashSet<Uuid> = match &query.selector {
728 StreamSelector::LogGroupIds(ids) => ids.iter().cloned().collect(),
729 };
730
731 for record_in_result in &results {
732 let mut record_belongs_to_a_selected_group = false;
733 for group in &log_groups_vec { if selected_group_ids_set.contains(&group.id) && (group.base_record().uid == record_in_result.uid || group.examples().iter().any(|ex| ex.uid == record_in_result.uid)) {
735 record_belongs_to_a_selected_group = true;
736 break;
737 }
738 }
739 prop_assert!(record_belongs_to_a_selected_group, "Record {} from results (content: '{}') does not belong to selected group. Selected: {:?}", record_in_result.uid, record_in_result.to_string(), selected_group_ids_set);
740
741 if let Some(filter) = &query.filter {
742 prop_assert!(record_in_result.to_string().contains(&filter.contains),
743 "Record {} (content: '{}') does not match filter '{}'", record_in_result.uid, record_in_result.to_string(), filter.contains);
744 }
745
746 let record_time = get_record_timestamp(record_in_result).expect("Record in results should have a timestamp");
747 prop_assert!(record_time >= start_time && record_time <= end_time,
748 "Record time {:?} for {} (content: '{}') is outside range [{:?}, {:?}]", record_time, record_in_result.uid, record_in_result.to_string(), start_time, end_time);
749 }
750
751 for group in &log_groups_vec { if selected_group_ids_set.contains(&group.id) {
753 let mut records_to_check = group.examples().clone();
754 records_to_check.push(group.base_record().clone());
755
756 for original_record in records_to_check {
757 let matches_filter = query.filter.as_ref().is_none_or(|f| original_record.to_string().contains(&f.contains));
758 let record_time_option = get_record_timestamp(&original_record);
759 prop_assert!(record_time_option.is_some(), "Original record {} (content: '{}') must have a timestamp for time range check.", original_record.uid, original_record.to_string());
760 let record_time = record_time_option.unwrap();
761 let matches_time = record_time >= start_time && record_time <= end_time;
762
763 if matches_filter && matches_time {
764 prop_assert!(results.iter().any(|res_rec| res_rec.uid == original_record.uid),
765 "Original record {} (content: '{}', time: {:?}) from group {} matches filter and time but not found in results. Query: {:?}, Filter: {:?}, Time Range: [{:?}, {:?}]",
766 original_record.uid, original_record.to_string(), record_time, group.id, query.selector, query.filter.as_ref().map(|f| &f.contains), start_time, end_time);
767 } else {
768 prop_assert!(!results.iter().any(|res_rec| res_rec.uid == original_record.uid),
769 "Original record {} (content: '{}', time: {:?}) from group {} (matches_filter: {}, matches_time: {}) found in results but should not be. Query: {:?}, Filter: {:?}, Time Range: [{:?}, {:?}]",
770 original_record.uid, original_record.to_string(), record_time, group.id, matches_filter, matches_time, query.selector, query.filter.as_ref().map(|f| &f.contains), start_time, end_time);
771 }
772 }
773 }
774 }
775 }
776 }
777}