master
rs 1,209 lines 39.4 KB
Raw
1 //! Integration tests for query pagination.
2 //!
3 //! These tests verify that pagination works correctly when querying log entries,
4 //! especially in the edge case where many entries share the same timestamp.
5
6 use journal_common::Seconds;
7 use journal_core::file::{JournalFile, JournalFileOptions, JournalWriter};
8 use journal_core::repository::File;
9 use journal_index::{
10 Anchor, Direction, FieldName, FileIndexer, LogQueryParamsBuilder, Microseconds,
11 };
12 use std::collections::HashSet;
13 use std::fs;
14 use std::path::PathBuf;
15 use tempfile::TempDir;
16 use uuid::Uuid;
17
18 // Helper constants
19 const JAN_1_2024_MIDNIGHT: Microseconds = Microseconds(1704067200_000_000);
20
21 /// Test journal entry specification
22 struct TestEntry {
23 timestamp: Microseconds,
24 fields: Vec<(String, String)>,
25 }
26
27 impl TestEntry {
28 fn new(timestamp: Microseconds) -> Self {
29 Self {
30 timestamp,
31 fields: Vec::new(),
32 }
33 }
34
35 fn with_field(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
36 self.fields.push((name.into(), value.into()));
37 self
38 }
39 }
40
41 /// Create a test journal file path that conforms to the expected format
42 fn create_test_journal_path(temp_dir: &TempDir) -> PathBuf {
43 let machine_id = Uuid::from_u128(0x12345678_1234_1234_1234_123456789abc);
44 let machine_dir = temp_dir.path().join(machine_id.to_string());
45 fs::create_dir_all(&machine_dir).expect("create machine dir");
46 machine_dir.join("system.journal")
47 }
48
49 /// Helper to create a test journal file with specified entries
50 fn create_test_journal(
51 entries: Vec<TestEntry>,
52 ) -> Result<(TempDir, File), Box<dyn std::error::Error>> {
53 let temp_dir = TempDir::new()?;
54 let journal_path = create_test_journal_path(&temp_dir);
55
56 let file =
57 File::from_path(&journal_path).ok_or("Failed to create repository File from path")?;
58
59 let machine_id = Uuid::from_u128(0x12345678_1234_1234_1234_123456789abc);
60 let boot_id = Uuid::from_u128(0x11111111_1111_1111_1111_111111111111);
61 let seqnum_id = Uuid::from_u128(0x22222222_2222_2222_2222_222222222222);
62
63 let options = JournalFileOptions::new(machine_id, boot_id, seqnum_id);
64
65 let mut journal_file = JournalFile::create(&file, options)?;
66 let mut writer = JournalWriter::new(&mut journal_file, 1, boot_id)?;
67
68 for entry in entries {
69 let mut entry_data = Vec::new();
70
71 // Add _SOURCE_REALTIME_TIMESTAMP first
72 entry_data.push(format!("_SOURCE_REALTIME_TIMESTAMP={}", entry.timestamp.0).into_bytes());
73
74 // Add all other fields
75 for (field, value) in entry.fields {
76 entry_data.push(format!("{}={}", field, value).into_bytes());
77 }
78
79 let entry_refs: Vec<&[u8]> = entry_data.iter().map(|v| v.as_slice()).collect();
80
81 writer.add_entry(
82 &mut journal_file,
83 &entry_refs,
84 entry.timestamp.0,
85 entry.timestamp.0,
86 )?;
87 }
88
89 Ok((temp_dir, file))
90 }
91
92 #[test]
93 fn test_pagination_forward_with_same_timestamps() {
94 // Create 300 entries all with the same timestamp
95 const TOTAL_ENTRIES: usize = 300;
96 const PAGE_SIZE: usize = 200;
97 let same_timestamp = JAN_1_2024_MIDNIGHT;
98
99 let entries: Vec<TestEntry> = (0..TOTAL_ENTRIES)
100 .map(|i| {
101 TestEntry::new(same_timestamp)
102 .with_field("MESSAGE", format!("Entry {}", i))
103 .with_field("ENTRY_ID", i.to_string())
104 })
105 .collect();
106
107 let (_temp_dir, file) = create_test_journal(entries).unwrap();
108
109 let mut indexer = FileIndexer::default();
110 let entry_id_field = FieldName::new("ENTRY_ID").unwrap();
111 let source_timestamp_field = FieldName::new("_SOURCE_REALTIME_TIMESTAMP").unwrap();
112 let file_index = indexer
113 .index(
114 &file,
115 Some(&source_timestamp_field),
116 &[entry_id_field],
117 Seconds(3600),
118 )
119 .unwrap();
120
121 let mut all_offsets = Vec::new();
122 let mut all_positions = HashSet::new();
123 let mut resume_position = None;
124
125 // First page
126 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
127 .with_limit(PAGE_SIZE)
128 .build()
129 .unwrap();
130
131 let results = file_index.find_log_entries(&file, &params).unwrap();
132 println!("First page: {} entries", results.len());
133 assert_eq!(
134 results.len(),
135 PAGE_SIZE,
136 "First page should return PAGE_SIZE entries"
137 );
138
139 // Verify all have the same timestamp
140 for entry in &results {
141 assert_eq!(entry.timestamp, same_timestamp);
142 all_offsets.push(entry.offset);
143 assert!(
144 all_positions.insert(entry.position),
145 "Position {} appeared twice",
146 entry.position
147 );
148 }
149
150 if let Some(last_entry) = results.last() {
151 resume_position = Some(last_entry.position);
152 }
153
154 // Second page - should get remaining 100 entries
155 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
156 .with_limit(PAGE_SIZE)
157 .with_resume_position(resume_position.unwrap())
158 .build()
159 .unwrap();
160
161 let results = file_index.find_log_entries(&file, &params).unwrap();
162 println!("Second page: {} entries", results.len());
163 assert_eq!(
164 results.len(),
165 TOTAL_ENTRIES - PAGE_SIZE,
166 "Second page should return remaining entries"
167 );
168
169 // Verify all have the same timestamp
170 for entry in &results {
171 assert_eq!(entry.timestamp, same_timestamp);
172 all_offsets.push(entry.offset);
173 assert!(
174 all_positions.insert(entry.position),
175 "Position {} appeared twice",
176 entry.position
177 );
178 }
179
180 if let Some(last_entry) = results.last() {
181 resume_position = Some(last_entry.position);
182 }
183
184 // Third page - should be empty
185 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
186 .with_limit(PAGE_SIZE)
187 .with_resume_position(resume_position.unwrap())
188 .build()
189 .unwrap();
190
191 let results = file_index.find_log_entries(&file, &params).unwrap();
192 println!("Third page: {} entries", results.len());
193 assert_eq!(results.len(), 0, "Third page should be empty");
194
195 // Verify we got all entries
196 assert_eq!(
197 all_offsets.len(),
198 TOTAL_ENTRIES,
199 "Should have retrieved all entries"
200 );
201
202 // Verify all offsets are unique (no duplicates)
203 let unique_offsets: HashSet<_> = all_offsets.iter().collect();
204 assert_eq!(
205 unique_offsets.len(),
206 TOTAL_ENTRIES,
207 "All offsets should be unique"
208 );
209
210 // Verify all positions are unique and contiguous
211 assert_eq!(
212 all_positions.len(),
213 TOTAL_ENTRIES,
214 "Should have unique positions"
215 );
216 for i in 0..TOTAL_ENTRIES {
217 assert!(all_positions.contains(&i), "Position {} missing", i);
218 }
219 }
220
221 #[test]
222 fn test_pagination_backward_with_same_timestamps() {
223 // Create 300 entries all with the same timestamp
224 const TOTAL_ENTRIES: usize = 300;
225 const PAGE_SIZE: usize = 200;
226 let same_timestamp = JAN_1_2024_MIDNIGHT;
227
228 let entries: Vec<TestEntry> = (0..TOTAL_ENTRIES)
229 .map(|i| {
230 TestEntry::new(same_timestamp)
231 .with_field("MESSAGE", format!("Entry {}", i))
232 .with_field("ENTRY_ID", i.to_string())
233 })
234 .collect();
235
236 let (_temp_dir, file) = create_test_journal(entries).unwrap();
237
238 let mut indexer = FileIndexer::default();
239 let entry_id_field = FieldName::new("ENTRY_ID").unwrap();
240 let source_timestamp_field = FieldName::new("_SOURCE_REALTIME_TIMESTAMP").unwrap();
241 let file_index = indexer
242 .index(
243 &file,
244 Some(&source_timestamp_field),
245 &[entry_id_field],
246 Seconds(3600),
247 )
248 .unwrap();
249
250 let mut all_offsets = Vec::new();
251 let mut all_positions = HashSet::new();
252 let mut resume_position = None;
253
254 // First page (from tail, going backward)
255 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
256 .with_limit(PAGE_SIZE)
257 .build()
258 .unwrap();
259
260 let results = file_index.find_log_entries(&file, &params).unwrap();
261 println!("First page: {} entries", results.len());
262 assert_eq!(
263 results.len(),
264 PAGE_SIZE,
265 "First page should return PAGE_SIZE entries"
266 );
267
268 // Verify all have the same timestamp
269 for entry in &results {
270 assert_eq!(entry.timestamp, same_timestamp);
271 all_offsets.push(entry.offset);
272 assert!(
273 all_positions.insert(entry.position),
274 "Position {} appeared twice",
275 entry.position
276 );
277 }
278
279 if let Some(last_entry) = results.last() {
280 resume_position = Some(last_entry.position);
281 }
282
283 // Second page - should get remaining 100 entries
284 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
285 .with_limit(PAGE_SIZE)
286 .with_resume_position(resume_position.unwrap())
287 .build()
288 .unwrap();
289
290 let results = file_index.find_log_entries(&file, &params).unwrap();
291 println!("Second page: {} entries", results.len());
292 assert_eq!(
293 results.len(),
294 TOTAL_ENTRIES - PAGE_SIZE,
295 "Second page should return remaining entries"
296 );
297
298 // Verify all have the same timestamp
299 for entry in &results {
300 assert_eq!(entry.timestamp, same_timestamp);
301 all_offsets.push(entry.offset);
302 assert!(
303 all_positions.insert(entry.position),
304 "Position {} appeared twice",
305 entry.position
306 );
307 }
308
309 if let Some(last_entry) = results.last() {
310 resume_position = Some(last_entry.position);
311 }
312
313 // Third page - should be empty
314 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
315 .with_limit(PAGE_SIZE)
316 .with_resume_position(resume_position.unwrap())
317 .build()
318 .unwrap();
319
320 let results = file_index.find_log_entries(&file, &params).unwrap();
321 println!("Third page: {} entries", results.len());
322 assert_eq!(results.len(), 0, "Third page should be empty");
323
324 // Verify we got all entries
325 assert_eq!(
326 all_offsets.len(),
327 TOTAL_ENTRIES,
328 "Should have retrieved all entries"
329 );
330
331 // Verify all offsets are unique (no duplicates)
332 let unique_offsets: HashSet<_> = all_offsets.iter().collect();
333 assert_eq!(
334 unique_offsets.len(),
335 TOTAL_ENTRIES,
336 "All offsets should be unique"
337 );
338
339 // Verify all positions are unique and contiguous
340 assert_eq!(
341 all_positions.len(),
342 TOTAL_ENTRIES,
343 "Should have unique positions"
344 );
345 for i in 0..TOTAL_ENTRIES {
346 assert!(all_positions.contains(&i), "Position {} missing", i);
347 }
348 }
349
350 #[test]
351 fn test_pagination_forward_with_mixed_timestamps() {
352 // Create entries with varying timestamps to ensure pagination works across different timestamps too
353 const ENTRIES_PER_TIMESTAMP: usize = 150;
354 const PAGE_SIZE: usize = 200;
355
356 let mut entries = Vec::new();
357
358 // First 150 entries at timestamp T
359 let timestamp1 = JAN_1_2024_MIDNIGHT;
360 for i in 0..ENTRIES_PER_TIMESTAMP {
361 entries.push(
362 TestEntry::new(timestamp1)
363 .with_field("MESSAGE", format!("Batch 1 Entry {}", i))
364 .with_field("ENTRY_ID", format!("1-{}", i)),
365 );
366 }
367
368 // Next 150 entries at timestamp T+1
369 let timestamp2 = Microseconds(timestamp1.0 + 1_000_000);
370 for i in 0..ENTRIES_PER_TIMESTAMP {
371 entries.push(
372 TestEntry::new(timestamp2)
373 .with_field("MESSAGE", format!("Batch 2 Entry {}", i))
374 .with_field("ENTRY_ID", format!("2-{}", i)),
375 );
376 }
377
378 let (_temp_dir, file) = create_test_journal(entries).unwrap();
379
380 let mut indexer = FileIndexer::default();
381 let entry_id_field = FieldName::new("ENTRY_ID").unwrap();
382 let source_timestamp_field = FieldName::new("_SOURCE_REALTIME_TIMESTAMP").unwrap();
383 let file_index = indexer
384 .index(
385 &file,
386 Some(&source_timestamp_field),
387 &[entry_id_field],
388 Seconds(3600),
389 )
390 .unwrap();
391
392 let mut all_offsets = Vec::new();
393 let mut all_positions = HashSet::new();
394
395 // First page - should get 200 entries (all 150 from timestamp1 + 50 from timestamp2)
396 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
397 .with_limit(PAGE_SIZE)
398 .build()
399 .unwrap();
400
401 let results = file_index.find_log_entries(&file, &params).unwrap();
402 println!("First page: {} entries", results.len());
403 assert_eq!(results.len(), PAGE_SIZE);
404
405 for entry in &results {
406 all_offsets.push(entry.offset);
407 assert!(all_positions.insert(entry.position));
408 }
409
410 let resume_position = results.last().unwrap().position;
411
412 // Second page - should get remaining 100 entries (all from timestamp2)
413 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
414 .with_limit(PAGE_SIZE)
415 .with_resume_position(resume_position)
416 .build()
417 .unwrap();
418
419 let results = file_index.find_log_entries(&file, &params).unwrap();
420 println!("Second page: {} entries", results.len());
421 assert_eq!(results.len(), 100);
422
423 // All entries in second page should have timestamp2
424 for entry in &results {
425 assert_eq!(entry.timestamp, timestamp2);
426 all_offsets.push(entry.offset);
427 assert!(all_positions.insert(entry.position));
428 }
429
430 // Verify we got all entries without duplicates
431 assert_eq!(all_offsets.len(), 300);
432 let unique_offsets: HashSet<_> = all_offsets.iter().collect();
433 assert_eq!(unique_offsets.len(), 300);
434 }
435
436 #[test]
437 fn test_pagination_empty_journal() {
438 // Create an empty journal file
439 let entries = Vec::new();
440
441 let (_temp_dir, file) = create_test_journal(entries).unwrap();
442
443 let mut indexer = FileIndexer::default();
444
445 // Indexing an empty journal should fail with EmptyHistogramInput
446 let result = indexer.index(&file, None, &[], Seconds(3600));
447 assert!(result.is_err(), "Empty journal should fail to index");
448 }
449
450 #[test]
451 fn test_pagination_single_entry() {
452 let timestamp = JAN_1_2024_MIDNIGHT;
453 let entries = vec![TestEntry::new(timestamp).with_field("MESSAGE", "Single entry")];
454
455 let (_temp_dir, file) = create_test_journal(entries).unwrap();
456
457 let mut indexer = FileIndexer::default();
458 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
459
460 // Query forward with large limit
461 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
462 .with_limit(100)
463 .build()
464 .unwrap();
465
466 let results = file_index.find_log_entries(&file, &params).unwrap();
467 assert_eq!(results.len(), 1, "Should return single entry");
468 assert_eq!(results[0].timestamp, timestamp);
469 assert_eq!(results[0].position, 0);
470
471 // Try to paginate from that position (should return empty)
472 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
473 .with_limit(100)
474 .with_resume_position(results[0].position)
475 .build()
476 .unwrap();
477
478 let results = file_index.find_log_entries(&file, &params).unwrap();
479 assert_eq!(results.len(), 0, "No more entries after single entry");
480
481 // Query backward
482 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
483 .with_limit(100)
484 .build()
485 .unwrap();
486
487 let results = file_index.find_log_entries(&file, &params).unwrap();
488 assert_eq!(results.len(), 1, "Should return single entry");
489 assert_eq!(results[0].timestamp, timestamp);
490 assert_eq!(results[0].position, 0);
491
492 // Try to paginate backward from position 0 (should return empty)
493 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
494 .with_limit(100)
495 .with_resume_position(0)
496 .build()
497 .unwrap();
498
499 let results = file_index.find_log_entries(&file, &params).unwrap();
500 assert_eq!(
501 results.len(),
502 0,
503 "Backward from position 0 should return empty"
504 );
505 }
506
507 #[test]
508 fn test_pagination_two_entries() {
509 let timestamp = JAN_1_2024_MIDNIGHT;
510 let entries = vec![
511 TestEntry::new(timestamp).with_field("MESSAGE", "Entry 1"),
512 TestEntry::new(timestamp).with_field("MESSAGE", "Entry 2"),
513 ];
514
515 let (_temp_dir, file) = create_test_journal(entries).unwrap();
516
517 let mut indexer = FileIndexer::default();
518 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
519
520 // Forward: Get both entries at once with limit 10
521 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
522 .with_limit(10)
523 .build()
524 .unwrap();
525
526 let results = file_index.find_log_entries(&file, &params).unwrap();
527 assert_eq!(results.len(), 2, "Should return both entries");
528 assert_eq!(results[0].position, 0);
529 assert_eq!(results[1].position, 1);
530
531 // Forward: Get first entry with limit 1, then paginate
532 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
533 .with_limit(1)
534 .build()
535 .unwrap();
536
537 let results = file_index.find_log_entries(&file, &params).unwrap();
538 assert_eq!(results.len(), 1, "Should return first entry");
539 assert_eq!(results[0].position, 0);
540
541 let first_position = results[0].position;
542
543 // Get second entry
544 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
545 .with_limit(1)
546 .with_resume_position(first_position)
547 .build()
548 .unwrap();
549
550 let results = file_index.find_log_entries(&file, &params).unwrap();
551 assert_eq!(results.len(), 1, "Should return second entry");
552 assert_eq!(results[0].position, 1);
553
554 // Try to get third entry (should be empty)
555 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
556 .with_limit(1)
557 .with_resume_position(1)
558 .build()
559 .unwrap();
560
561 let results = file_index.find_log_entries(&file, &params).unwrap();
562 assert_eq!(results.len(), 0, "No third entry");
563
564 // Backward: Get both entries at once
565 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
566 .with_limit(10)
567 .build()
568 .unwrap();
569
570 let results = file_index.find_log_entries(&file, &params).unwrap();
571 assert_eq!(results.len(), 2, "Should return both entries backward");
572
573 // Backward: Get last entry with limit 1, then paginate
574 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
575 .with_limit(1)
576 .build()
577 .unwrap();
578
579 let results = file_index.find_log_entries(&file, &params).unwrap();
580 assert_eq!(results.len(), 1, "Should return last entry");
581 assert_eq!(results[0].position, 1);
582
583 // Get first entry going backward
584 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
585 .with_limit(1)
586 .with_resume_position(1)
587 .build()
588 .unwrap();
589
590 let results = file_index.find_log_entries(&file, &params).unwrap();
591 assert_eq!(results.len(), 1, "Should return first entry");
592 assert_eq!(results[0].position, 0);
593 }
594
595 #[test]
596 fn test_pagination_limit_zero() {
597 let timestamp = JAN_1_2024_MIDNIGHT;
598 let entries = vec![
599 TestEntry::new(timestamp).with_field("MESSAGE", "Entry 1"),
600 TestEntry::new(timestamp).with_field("MESSAGE", "Entry 2"),
601 TestEntry::new(timestamp).with_field("MESSAGE", "Entry 3"),
602 ];
603
604 let (_temp_dir, file) = create_test_journal(entries).unwrap();
605
606 let mut indexer = FileIndexer::default();
607 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
608
609 // Query with limit 0 should return empty results
610 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
611 .with_limit(0)
612 .build()
613 .unwrap();
614
615 let results = file_index.find_log_entries(&file, &params).unwrap();
616 assert_eq!(results.len(), 0, "Limit 0 should return no results");
617
618 // Same for backward
619 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
620 .with_limit(0)
621 .build()
622 .unwrap();
623
624 let results = file_index.find_log_entries(&file, &params).unwrap();
625 assert_eq!(results.len(), 0, "Limit 0 should return no results");
626 }
627
628 #[test]
629 fn test_pagination_limit_exact_match() {
630 // Create exactly 50 entries
631 const TOTAL_ENTRIES: usize = 50;
632 let timestamp = JAN_1_2024_MIDNIGHT;
633
634 let entries: Vec<TestEntry> = (0..TOTAL_ENTRIES)
635 .map(|i| TestEntry::new(timestamp).with_field("ENTRY_ID", i.to_string()))
636 .collect();
637
638 let (_temp_dir, file) = create_test_journal(entries).unwrap();
639
640 let mut indexer = FileIndexer::default();
641 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
642
643 // Query with limit exactly equal to total entries
644 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
645 .with_limit(TOTAL_ENTRIES)
646 .build()
647 .unwrap();
648
649 let results = file_index.find_log_entries(&file, &params).unwrap();
650 assert_eq!(results.len(), TOTAL_ENTRIES, "Should return all entries");
651
652 // Try to paginate from last position (should return empty)
653 let last_position = results.last().unwrap().position;
654 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
655 .with_limit(TOTAL_ENTRIES)
656 .with_resume_position(last_position)
657 .build()
658 .unwrap();
659
660 let results = file_index.find_log_entries(&file, &params).unwrap();
661 assert_eq!(results.len(), 0, "No more entries after exact match");
662 }
663
664 #[test]
665 fn test_pagination_limit_exceeds_total() {
666 // Create 10 entries but query with limit 1000
667 const TOTAL_ENTRIES: usize = 10;
668 const LARGE_LIMIT: usize = 1000;
669 let timestamp = JAN_1_2024_MIDNIGHT;
670
671 let entries: Vec<TestEntry> = (0..TOTAL_ENTRIES)
672 .map(|i| TestEntry::new(timestamp).with_field("ENTRY_ID", i.to_string()))
673 .collect();
674
675 let (_temp_dir, file) = create_test_journal(entries).unwrap();
676
677 let mut indexer = FileIndexer::default();
678 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
679
680 // Query with limit much larger than total entries
681 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
682 .with_limit(LARGE_LIMIT)
683 .build()
684 .unwrap();
685
686 let results = file_index.find_log_entries(&file, &params).unwrap();
687 assert_eq!(
688 results.len(),
689 TOTAL_ENTRIES,
690 "Should return all entries (not more than available)"
691 );
692
693 // Verify all positions are present
694 for (i, entry) in results.iter().enumerate() {
695 assert_eq!(entry.position, i);
696 }
697
698 // Same for backward
699 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
700 .with_limit(LARGE_LIMIT)
701 .build()
702 .unwrap();
703
704 let results = file_index.find_log_entries(&file, &params).unwrap();
705 assert_eq!(
706 results.len(),
707 TOTAL_ENTRIES,
708 "Should return all entries backward"
709 );
710 }
711
712 #[test]
713 fn test_pagination_resume_out_of_bounds() {
714 // Create 10 entries
715 const TOTAL_ENTRIES: usize = 10;
716 let timestamp = JAN_1_2024_MIDNIGHT;
717
718 let entries: Vec<TestEntry> = (0..TOTAL_ENTRIES)
719 .map(|i| TestEntry::new(timestamp).with_field("ENTRY_ID", i.to_string()))
720 .collect();
721
722 let (_temp_dir, file) = create_test_journal(entries).unwrap();
723
724 let mut indexer = FileIndexer::default();
725 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
726
727 // Forward: Resume from position equal to total entries (at boundary)
728 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
729 .with_limit(10)
730 .with_resume_position(TOTAL_ENTRIES - 1)
731 .build()
732 .unwrap();
733
734 let results = file_index.find_log_entries(&file, &params).unwrap();
735 assert_eq!(
736 results.len(),
737 0,
738 "Resume from last position should return empty"
739 );
740
741 // Forward: Resume from position beyond total entries
742 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
743 .with_limit(10)
744 .with_resume_position(TOTAL_ENTRIES)
745 .build()
746 .unwrap();
747
748 let results = file_index.find_log_entries(&file, &params).unwrap();
749 assert_eq!(
750 results.len(),
751 0,
752 "Resume from beyond last position should return empty"
753 );
754
755 // Forward: Resume from way beyond total entries
756 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
757 .with_limit(10)
758 .with_resume_position(999)
759 .build()
760 .unwrap();
761
762 let results = file_index.find_log_entries(&file, &params).unwrap();
763 assert_eq!(
764 results.len(),
765 0,
766 "Resume from way beyond should return empty (not panic)"
767 );
768
769 // Backward: Resume from position 0 returns empty (already tested but for completeness)
770 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
771 .with_limit(10)
772 .with_resume_position(0)
773 .build()
774 .unwrap();
775
776 let results = file_index.find_log_entries(&file, &params).unwrap();
777 assert_eq!(
778 results.len(),
779 0,
780 "Backward from position 0 should return empty"
781 );
782
783 // Backward: Resume from position equal to total entries
784 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
785 .with_limit(10)
786 .with_resume_position(TOTAL_ENTRIES)
787 .build()
788 .unwrap();
789
790 let results = file_index.find_log_entries(&file, &params).unwrap();
791 assert_eq!(
792 results.len(),
793 0,
794 "Backward from position equal to total should return empty (not panic)"
795 );
796
797 // Backward: Resume from position beyond total entries
798 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
799 .with_limit(10)
800 .with_resume_position(TOTAL_ENTRIES + 5)
801 .build()
802 .unwrap();
803
804 let results = file_index.find_log_entries(&file, &params).unwrap();
805 assert_eq!(
806 results.len(),
807 0,
808 "Backward from beyond total should return empty (not panic)"
809 );
810
811 // Backward: Resume from way beyond total entries
812 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
813 .with_limit(10)
814 .with_resume_position(999)
815 .build()
816 .unwrap();
817
818 let results = file_index.find_log_entries(&file, &params).unwrap();
819 assert_eq!(
820 results.len(),
821 0,
822 "Backward from way beyond should return empty (not panic)"
823 );
824 }
825
826 #[test]
827 fn test_pagination_anchor_before_all_entries() {
828 // Create entries at timestamps 10:00, 11:00, 12:00
829 let base_timestamp = JAN_1_2024_MIDNIGHT;
830 let entries = vec![
831 TestEntry::new(Microseconds(base_timestamp.0 + 10 * 3600_000_000))
832 .with_field("ENTRY_ID", "0"),
833 TestEntry::new(Microseconds(base_timestamp.0 + 11 * 3600_000_000))
834 .with_field("ENTRY_ID", "1"),
835 TestEntry::new(Microseconds(base_timestamp.0 + 12 * 3600_000_000))
836 .with_field("ENTRY_ID", "2"),
837 ];
838
839 let (_temp_dir, file) = create_test_journal(entries).unwrap();
840
841 let mut indexer = FileIndexer::default();
842 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
843
844 // Anchor at 09:00 (before all entries), going forward
845 let anchor_timestamp = Microseconds(base_timestamp.0 + 9 * 3600_000_000);
846 let params =
847 LogQueryParamsBuilder::new(Anchor::Timestamp(anchor_timestamp), Direction::Forward)
848 .with_limit(10)
849 .build()
850 .unwrap();
851
852 let results = file_index.find_log_entries(&file, &params).unwrap();
853 assert_eq!(
854 results.len(),
855 3,
856 "Forward from before all entries should return all entries"
857 );
858
859 // Anchor at 09:00, going backward
860 let params =
861 LogQueryParamsBuilder::new(Anchor::Timestamp(anchor_timestamp), Direction::Backward)
862 .with_limit(10)
863 .build()
864 .unwrap();
865
866 let results = file_index.find_log_entries(&file, &params).unwrap();
867 assert_eq!(
868 results.len(),
869 0,
870 "Backward from before all entries should return no entries"
871 );
872 }
873
874 #[test]
875 fn test_pagination_anchor_after_all_entries() {
876 // Create entries at timestamps 10:00, 11:00, 12:00
877 let base_timestamp = JAN_1_2024_MIDNIGHT;
878 let entries = vec![
879 TestEntry::new(Microseconds(base_timestamp.0 + 10 * 3600_000_000))
880 .with_field("ENTRY_ID", "0"),
881 TestEntry::new(Microseconds(base_timestamp.0 + 11 * 3600_000_000))
882 .with_field("ENTRY_ID", "1"),
883 TestEntry::new(Microseconds(base_timestamp.0 + 12 * 3600_000_000))
884 .with_field("ENTRY_ID", "2"),
885 ];
886
887 let (_temp_dir, file) = create_test_journal(entries).unwrap();
888
889 let mut indexer = FileIndexer::default();
890 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
891
892 // Anchor at 13:00 (after all entries), going forward
893 let anchor_timestamp = Microseconds(base_timestamp.0 + 13 * 3600_000_000);
894 let params =
895 LogQueryParamsBuilder::new(Anchor::Timestamp(anchor_timestamp), Direction::Forward)
896 .with_limit(10)
897 .build()
898 .unwrap();
899
900 let results = file_index.find_log_entries(&file, &params).unwrap();
901 assert_eq!(
902 results.len(),
903 0,
904 "Forward from after all entries should return no entries"
905 );
906
907 // Anchor at 13:00, going backward
908 let params =
909 LogQueryParamsBuilder::new(Anchor::Timestamp(anchor_timestamp), Direction::Backward)
910 .with_limit(10)
911 .build()
912 .unwrap();
913
914 let results = file_index.find_log_entries(&file, &params).unwrap();
915 assert_eq!(
916 results.len(),
917 3,
918 "Backward from after all entries should return all entries"
919 );
920 }
921
922 #[test]
923 fn test_pagination_anchor_in_middle_with_pagination() {
924 // Create entries at timestamps 10:00, 11:00, 12:00, 13:00, 14:00
925 let base_timestamp = JAN_1_2024_MIDNIGHT;
926 let entries = vec![
927 TestEntry::new(Microseconds(base_timestamp.0 + 10 * 3600_000_000))
928 .with_field("ENTRY_ID", "0"),
929 TestEntry::new(Microseconds(base_timestamp.0 + 11 * 3600_000_000))
930 .with_field("ENTRY_ID", "1"),
931 TestEntry::new(Microseconds(base_timestamp.0 + 12 * 3600_000_000))
932 .with_field("ENTRY_ID", "2"),
933 TestEntry::new(Microseconds(base_timestamp.0 + 13 * 3600_000_000))
934 .with_field("ENTRY_ID", "3"),
935 TestEntry::new(Microseconds(base_timestamp.0 + 14 * 3600_000_000))
936 .with_field("ENTRY_ID", "4"),
937 ];
938
939 let (_temp_dir, file) = create_test_journal(entries).unwrap();
940
941 let mut indexer = FileIndexer::default();
942 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
943
944 // Anchor at 12:00 (middle), going forward with limit 2
945 let anchor_timestamp = Microseconds(base_timestamp.0 + 12 * 3600_000_000);
946 let params =
947 LogQueryParamsBuilder::new(Anchor::Timestamp(anchor_timestamp), Direction::Forward)
948 .with_limit(2)
949 .build()
950 .unwrap();
951
952 let results = file_index.find_log_entries(&file, &params).unwrap();
953 assert_eq!(
954 results.len(),
955 2,
956 "Should return 2 entries starting from anchor"
957 );
958 // Should get entries at 12:00 and 13:00 (positions 2 and 3)
959 assert_eq!(results[0].position, 2);
960 assert_eq!(results[1].position, 3);
961
962 // Paginate forward to get the rest
963 let params =
964 LogQueryParamsBuilder::new(Anchor::Timestamp(anchor_timestamp), Direction::Forward)
965 .with_limit(2)
966 .with_resume_position(results[1].position)
967 .build()
968 .unwrap();
969
970 let results = file_index.find_log_entries(&file, &params).unwrap();
971 assert_eq!(results.len(), 1, "Should return remaining 1 entry");
972 assert_eq!(results[0].position, 4);
973
974 // Anchor at 12:00, going backward with limit 2
975 let params =
976 LogQueryParamsBuilder::new(Anchor::Timestamp(anchor_timestamp), Direction::Backward)
977 .with_limit(2)
978 .build()
979 .unwrap();
980
981 let results = file_index.find_log_entries(&file, &params).unwrap();
982 assert_eq!(
983 results.len(),
984 2,
985 "Should return 2 entries backward from anchor"
986 );
987 // Should get entries at 12:00 and 11:00 (positions 2 and 1)
988 assert_eq!(results[0].position, 2);
989 assert_eq!(results[1].position, 1);
990
991 // Paginate backward to get the rest
992 let params =
993 LogQueryParamsBuilder::new(Anchor::Timestamp(anchor_timestamp), Direction::Backward)
994 .with_limit(2)
995 .with_resume_position(results[1].position)
996 .build()
997 .unwrap();
998
999 let results = file_index.find_log_entries(&file, &params).unwrap();
1000 assert_eq!(results.len(), 1, "Should return remaining 1 entry");
1001 assert_eq!(results[0].position, 0);
1002 }
1003
1004 #[test]
1005 fn test_pagination_with_time_boundaries() {
1006 // Create entries at different timestamps
1007 let base_timestamp = JAN_1_2024_MIDNIGHT;
1008 let entries: Vec<TestEntry> = (0..20)
1009 .map(|i| {
1010 // Entry at hour i
1011 TestEntry::new(Microseconds(base_timestamp.0 + i * 3600_000_000))
1012 .with_field("ENTRY_ID", i.to_string())
1013 })
1014 .collect();
1015
1016 let (_temp_dir, file) = create_test_journal(entries).unwrap();
1017
1018 let mut indexer = FileIndexer::default();
1019 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
1020
1021 // Query with after and before boundaries: entries from hour 5 to hour 15 (exclusive)
1022 // That's entries 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 (10 entries total)
1023 let after = Microseconds(base_timestamp.0 + 5 * 3600_000_000);
1024 let before = Microseconds(base_timestamp.0 + 15 * 3600_000_000);
1025
1026 // First page with limit 4
1027 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
1028 .with_after(after)
1029 .with_before(before)
1030 .with_limit(4)
1031 .build()
1032 .unwrap();
1033
1034 let results = file_index.find_log_entries(&file, &params).unwrap();
1035 assert_eq!(results.len(), 4, "First page should return 4 entries");
1036 // Should get entries 5, 6, 7, 8
1037 assert_eq!(results[0].position, 5);
1038 assert_eq!(results[3].position, 8);
1039
1040 let mut all_results = results.clone();
1041
1042 // Second page
1043 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
1044 .with_after(after)
1045 .with_before(before)
1046 .with_limit(4)
1047 .with_resume_position(results.last().unwrap().position)
1048 .build()
1049 .unwrap();
1050
1051 let results = file_index.find_log_entries(&file, &params).unwrap();
1052 assert_eq!(results.len(), 4, "Second page should return 4 entries");
1053 // Should get entries 9, 10, 11, 12
1054 assert_eq!(results[0].position, 9);
1055 assert_eq!(results[3].position, 12);
1056
1057 all_results.extend(results.clone());
1058
1059 // Third page
1060 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
1061 .with_after(after)
1062 .with_before(before)
1063 .with_limit(4)
1064 .with_resume_position(results.last().unwrap().position)
1065 .build()
1066 .unwrap();
1067
1068 let results = file_index.find_log_entries(&file, &params).unwrap();
1069 assert_eq!(
1070 results.len(),
1071 2,
1072 "Third page should return remaining 2 entries"
1073 );
1074 // Should get entries 13, 14
1075 assert_eq!(results[0].position, 13);
1076 assert_eq!(results[1].position, 14);
1077
1078 all_results.extend(results.clone());
1079
1080 // Fourth page (should be empty)
1081 let params = LogQueryParamsBuilder::new(Anchor::Head, Direction::Forward)
1082 .with_after(after)
1083 .with_before(before)
1084 .with_limit(4)
1085 .with_resume_position(results.last().unwrap().position)
1086 .build()
1087 .unwrap();
1088
1089 let results = file_index.find_log_entries(&file, &params).unwrap();
1090 assert_eq!(results.len(), 0, "Fourth page should be empty");
1091
1092 // Verify we got exactly 10 entries total
1093 assert_eq!(all_results.len(), 10);
1094
1095 // Verify all timestamps are within boundaries
1096 for entry in &all_results {
1097 assert!(
1098 entry.timestamp.0 >= after.0,
1099 "Entry timestamp should be >= after boundary"
1100 );
1101 assert!(
1102 entry.timestamp.0 < before.0,
1103 "Entry timestamp should be < before boundary"
1104 );
1105 }
1106 }
1107
1108 #[test]
1109 fn test_pagination_backward_with_time_boundaries() {
1110 // Create entries at different timestamps
1111 let base_timestamp = JAN_1_2024_MIDNIGHT;
1112 let entries: Vec<TestEntry> = (0..20)
1113 .map(|i| {
1114 TestEntry::new(Microseconds(base_timestamp.0 + i * 3600_000_000))
1115 .with_field("ENTRY_ID", i.to_string())
1116 })
1117 .collect();
1118
1119 let (_temp_dir, file) = create_test_journal(entries).unwrap();
1120
1121 let mut indexer = FileIndexer::default();
1122 let file_index = indexer.index(&file, None, &[], Seconds(3600)).unwrap();
1123
1124 // Query backward with boundaries: entries from hour 5 to hour 15 (exclusive)
1125 // That's entries 5-14 (10 entries total), going backward from 14 to 5
1126 let after = Microseconds(base_timestamp.0 + 5 * 3600_000_000);
1127 let before = Microseconds(base_timestamp.0 + 15 * 3600_000_000);
1128
1129 // First page with limit 4
1130 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
1131 .with_after(after)
1132 .with_before(before)
1133 .with_limit(4)
1134 .build()
1135 .unwrap();
1136
1137 let results = file_index.find_log_entries(&file, &params).unwrap();
1138 assert_eq!(results.len(), 4, "First page should return 4 entries");
1139 // Going backward, should get 14, 13, 12, 11
1140 assert_eq!(results[0].position, 14);
1141 assert_eq!(results[3].position, 11);
1142
1143 let mut all_results = results.clone();
1144
1145 // Second page
1146 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
1147 .with_after(after)
1148 .with_before(before)
1149 .with_limit(4)
1150 .with_resume_position(results.last().unwrap().position)
1151 .build()
1152 .unwrap();
1153
1154 let results = file_index.find_log_entries(&file, &params).unwrap();
1155 assert_eq!(results.len(), 4, "Second page should return 4 entries");
1156 // Should get 10, 9, 8, 7
1157 assert_eq!(results[0].position, 10);
1158 assert_eq!(results[3].position, 7);
1159
1160 all_results.extend(results.clone());
1161
1162 // Third page
1163 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
1164 .with_after(after)
1165 .with_before(before)
1166 .with_limit(4)
1167 .with_resume_position(results.last().unwrap().position)
1168 .build()
1169 .unwrap();
1170
1171 let results = file_index.find_log_entries(&file, &params).unwrap();
1172 assert_eq!(
1173 results.len(),
1174 2,
1175 "Third page should return remaining 2 entries"
1176 );
1177 // Should get 6, 5
1178 assert_eq!(results[0].position, 6);
1179 assert_eq!(results[1].position, 5);
1180
1181 all_results.extend(results.clone());
1182
1183 // Fourth page (should be empty)
1184 let params = LogQueryParamsBuilder::new(Anchor::Tail, Direction::Backward)
1185 .with_after(after)
1186 .with_before(before)
1187 .with_limit(4)
1188 .with_resume_position(results.last().unwrap().position)
1189 .build()
1190 .unwrap();
1191
1192 let results = file_index.find_log_entries(&file, &params).unwrap();
1193 assert_eq!(results.len(), 0, "Fourth page should be empty");
1194
1195 // Verify we got exactly 10 entries total
1196 assert_eq!(all_results.len(), 10);
1197
1198 // Verify all timestamps are within boundaries
1199 for entry in &all_results {
1200 assert!(
1201 entry.timestamp.0 >= after.0,
1202 "Entry timestamp should be >= after boundary"
1203 );
1204 assert!(
1205 entry.timestamp.0 < before.0,
1206 "Entry timestamp should be < before boundary"
1207 );
1208 }
1209 }