Keep window manager state consistent on `mmap` failures. (#21693)
* Fix window-manager state consistency after failed mmap Invalidate `active_window_idx` before/after window removal to ensure the index doesn't point to a non-existent window if `create_window` fails. Also adds error logging when mmap creation fails, and two separate tests to ensure the window manager's consistency in such cases. * Improve error handling at journal file access call-sites - Stop `EntryDataIterator` on data read errors instead of continuing - Add warning log when find_log_entries fails for a file - Properly propagate errors from `get_timestamp_field`, ignoring only `MissingFieldName` variant.
vkalintiris committed
Feb 2, 2026 at 15:51 UTC
c56d3582e5a65dbe287f2cc4ee76646e9f2211a2
4 files changed
+239
-7
src/crates/journal-core/src/file/file.rs
+5
-1
@@ -1158,7 +1158,11 @@ impl<'a, M: MemoryMap> Iterator for EntryDataIterator<'a, M> {
1158
// Try to get the data object
1159
match self.journal.data_ref(data_offset) {
1160
Ok(data_guard) => Some(Ok(data_guard)),
1161
- Err(e) => Some(Err(e)),
1161
+ Err(e) => {
1162
+ // If we can't read the data, return the error and stop iteration
1163
+ self.current_index = self.total_items;
1164
+ Some(Err(e))
1165
+ }
1166
}
1167
}
1168
Err(e) => {
src/crates/journal-core/src/file/mmap.rs
+223
-3
@@ -2,6 +2,7 @@ use crate::error::Result;
2
use journal_common::compat::is_multiple_of;
3
use std::fs::File;
4
use std::ops::{Deref, DerefMut};
5
+use tracing::error;
6
7
// Re-export memmap2 types for other crates and import for internal use
8
pub use memmap2::{Mmap, MmapMut, MmapOptions};
@@ -141,7 +142,16 @@ impl<M: MemoryMap> WindowManager<M> {
142
debug_assert_ne!(chunk_count, 0);
143
144
let size = chunk_count * self.chunk_size;
144
- let mmap = M::create(&self.file, window_start, size)?;
145
+ let mmap = M::create(&self.file, window_start, size).map_err(|e| {
146
+ error!(
147
+ window_start,
148
+ size,
149
+ chunk_count,
150
+ chunk_size = self.chunk_size,
151
+ "mmap failed: {e}"
152
+ );
153
+ e
154
+ })?;
155
Ok(Window {
156
offset: window_start,
157
size,
@@ -197,6 +207,9 @@ impl<M: MemoryMap> WindowManager<M> {
207
// Remap the window
208
209
let window = self.windows.remove(idx);
210
+ // Invalidate active_window_idx before removal to maintain consistency.
211
+ // If create_window fails, the index won't point to a non-existent window.
212
+ self.active_window_idx = None;
213
214
let window_start = window.offset;
215
let window_end = self.get_chunk_aligned_end(position + size_needed);
@@ -212,10 +225,11 @@ impl<M: MemoryMap> WindowManager<M> {
225
226
if self.windows.len() >= self.max_windows {
227
self.windows.remove(self.find_window_to_evict());
228
+ // Invalidate active_window_idx after removal to maintain consistency.
229
+ // If create_window fails below, the index won't point to a non-existent window.
230
+ self.active_window_idx = None;
231
}
232
217
- // NOTE: the active window index might have been invalidated. In
218
- // the scope that follows, we should not use code that relies on it.
233
{
234
// Calculate window start for this position
235
let window_start = self.get_chunk_aligned_start(position);
@@ -250,3 +264,209 @@ impl<M: MemoryMapMut> WindowManager<M> {
264
Ok(())
265
}
266
}
267
+
268
+#[cfg(test)]
269
+mod tests {
270
+ use super::*;
271
+ use crate::error::JournalError;
272
+ use std::cell::Cell;
273
+ use std::io::Write;
274
+ use std::rc::Rc;
275
+ use tempfile::NamedTempFile;
276
+
277
+ const PAGE_SIZE_TEST: u64 = 4096;
278
+
279
+ /// A mock MemoryMap that can be configured to fail on specific calls.
280
+ /// This allows us to test error handling in WindowManager.
281
+ struct FailingMmap {
282
+ data: Vec<u8>,
283
+ }
284
+
285
+ impl Deref for FailingMmap {
286
+ type Target = [u8];
287
+ fn deref(&self) -> &[u8] {
288
+ &self.data
289
+ }
290
+ }
291
+
292
+ /// Shared state to control when the mock should fail
293
+ struct MockController {
294
+ fail_next_create: Cell<bool>,
295
+ create_count: Cell<usize>,
296
+ }
297
+
298
+ impl MockController {
299
+ fn new() -> Self {
300
+ Self {
301
+ fail_next_create: Cell::new(false),
302
+ create_count: Cell::new(0),
303
+ }
304
+ }
305
+
306
+ fn set_fail_next(&self, fail: bool) {
307
+ self.fail_next_create.set(fail);
308
+ }
309
+
310
+ fn should_fail(&self) -> bool {
311
+ let count = self.create_count.get();
312
+ self.create_count.set(count + 1);
313
+ self.fail_next_create.get()
314
+ }
315
+ }
316
+
317
+ // Thread-local controller for the mock
318
+ thread_local! {
319
+ static MOCK_CONTROLLER: Rc<MockController> = Rc::new(MockController::new());
320
+ }
321
+
322
+ impl MemoryMap for FailingMmap {
323
+ fn create(_file: &File, _offset: u64, size: u64) -> Result<Self> {
324
+ MOCK_CONTROLLER.with(|ctrl| {
325
+ if ctrl.should_fail() {
326
+ return Err(JournalError::Io(std::io::Error::new(
327
+ std::io::ErrorKind::Other,
328
+ "simulated mmap failure",
329
+ )));
330
+ }
331
+ // Create a mock mmap with zeros
332
+ Ok(FailingMmap {
333
+ data: vec![0u8; size as usize],
334
+ })
335
+ })
336
+ }
337
+ }
338
+
339
+ /// This test verifies that WindowManager maintains consistent state
340
+ /// after a failed remap operation.
341
+ ///
342
+ /// The scenario:
343
+ /// 1. A window exists at some position
344
+ /// 2. A request comes in that requires remapping (position in window, but size extends beyond)
345
+ /// 3. The old window is removed
346
+ /// 4. Creating the new (larger) window fails (e.g., mmap error)
347
+ /// 5. The WindowManager should remain in a consistent state
348
+ /// 6. Subsequent operations should not panic
349
+ #[test]
350
+ fn test_consistent_state_after_failed_remap() {
351
+ // Create a temporary file (content doesn't matter for mock)
352
+ let mut temp_file = NamedTempFile::new().unwrap();
353
+ temp_file.write_all(&[0u8; 8192]).unwrap();
354
+ temp_file.flush().unwrap();
355
+
356
+ let file = File::open(temp_file.path()).unwrap();
357
+
358
+ // Create WindowManager with mock mmap, 4KB chunks, max 1 window
359
+ let mut wm: WindowManager<FailingMmap> =
360
+ WindowManager::new(file, PAGE_SIZE_TEST, 1).unwrap();
361
+
362
+ // Reset controller state
363
+ MOCK_CONTROLLER.with(|ctrl| {
364
+ ctrl.set_fail_next(false);
365
+ ctrl.create_count.set(0);
366
+ });
367
+
368
+ // First read: creates a window at offset 0, size 4KB (this should succeed)
369
+ {
370
+ let slice = wm.get_slice(0, 100).unwrap();
371
+ assert_eq!(slice.len(), 100);
372
+ }
373
+ assert_eq!(wm.windows.len(), 1);
374
+ assert_eq!(wm.active_window_idx, Some(0));
375
+
376
+ // Configure mock to fail on the next create call
377
+ MOCK_CONTROLLER.with(|ctrl| ctrl.set_fail_next(true));
378
+
379
+ // Request a slice that requires remapping:
380
+ // - Position 100 is within the existing window [0, 4096)
381
+ // - But size 4000 means we need bytes [100, 4100), which extends beyond window
382
+ // - This triggers the "Remap the window" branch
383
+ // - The old window is removed
384
+ // - Then create_window is called and FAILS
385
+ let remap_result = wm.get_slice(100, 4000);
386
+ assert!(remap_result.is_err(), "Expected remap to fail");
387
+
388
+ // Verify state is consistent after the failure:
389
+ // - windows is empty (the old window was removed, new one failed to create)
390
+ // - active_window_idx should be None (not pointing to non-existent window)
391
+ assert_eq!(wm.windows.len(), 0);
392
+ assert_eq!(wm.active_window_idx, None);
393
+
394
+ // Allow the next create to succeed
395
+ MOCK_CONTROLLER.with(|ctrl| ctrl.set_fail_next(false));
396
+
397
+ // The next operation should NOT panic - it should succeed by creating a new window
398
+ let result = wm.get_slice(0, 100);
399
+ assert!(
400
+ result.is_ok(),
401
+ "Expected get_slice to succeed after recovery"
402
+ );
403
+ assert_eq!(wm.windows.len(), 1);
404
+ }
405
+
406
+ /// This test verifies that WindowManager maintains consistent state
407
+ /// after a failed window creation in the eviction path.
408
+ ///
409
+ /// The scenario:
410
+ /// 1. A window exists and we're at max_windows
411
+ /// 2. A request comes in for a different region requiring a new window
412
+ /// 3. The old window is evicted to make room
413
+ /// 4. Creating the new window fails (e.g., mmap error)
414
+ /// 5. The WindowManager should remain in a consistent state
415
+ /// 6. Subsequent operations should not panic
416
+ #[test]
417
+ fn test_consistent_state_after_failed_eviction() {
418
+ // Create a temporary file
419
+ let mut temp_file = NamedTempFile::new().unwrap();
420
+ temp_file.write_all(&[0u8; 8192]).unwrap();
421
+ temp_file.flush().unwrap();
422
+
423
+ let file = File::open(temp_file.path()).unwrap();
424
+
425
+ // Create WindowManager with mock mmap, 4KB chunks, max 1 window
426
+ let mut wm: WindowManager<FailingMmap> =
427
+ WindowManager::new(file, PAGE_SIZE_TEST, 1).unwrap();
428
+
429
+ // Reset controller state
430
+ MOCK_CONTROLLER.with(|ctrl| {
431
+ ctrl.set_fail_next(false);
432
+ ctrl.create_count.set(0);
433
+ });
434
+
435
+ // Create first window at offset 0
436
+ {
437
+ let _slice = wm.get_slice(0, 100).unwrap();
438
+ }
439
+ assert_eq!(wm.windows.len(), 1);
440
+ assert_eq!(wm.active_window_idx, Some(0));
441
+
442
+ // Configure mock to fail on the next create call
443
+ MOCK_CONTROLLER.with(|ctrl| ctrl.set_fail_next(true));
444
+
445
+ // Request a slice at a completely different position (second page)
446
+ // This triggers:
447
+ // - lookup_window_by_range returns None (position 4096 not in window [0, 4096))
448
+ // - lookup_window_by_position returns None
449
+ // - "Create a brand new window" branch
450
+ // - Eviction: windows.remove(0) since we're at max_windows
451
+ // - create_window fails
452
+ let result = wm.get_slice(4096, 100);
453
+ assert!(result.is_err(), "Expected mmap to fail");
454
+
455
+ // Verify state is consistent after the failure:
456
+ // - windows is empty (the old window was evicted, new one failed to create)
457
+ // - active_window_idx should be None (not pointing to non-existent window)
458
+ assert_eq!(wm.windows.len(), 0);
459
+ assert_eq!(wm.active_window_idx, None);
460
+
461
+ // Allow the next create to succeed
462
+ MOCK_CONTROLLER.with(|ctrl| ctrl.set_fail_next(false));
463
+
464
+ // The next operation should NOT panic - it should succeed by creating a new window
465
+ let result = wm.get_slice(0, 100);
466
+ assert!(
467
+ result.is_ok(),
468
+ "Expected get_slice to succeed after recovery"
469
+ );
470
+ assert_eq!(wm.windows.len(), 1);
471
+ }
472
+}
src/crates/journal-engine/src/logs/query.rs
+5
-1
@@ -13,6 +13,7 @@ use journal_index::{
13
use journal_registry::File;
14
use std::collections::HashMap;
15
use std::num::NonZeroU64;
16
+use tracing::warn;
17
18
/// Pagination state for multi-file log queries.
19
///
@@ -314,7 +315,10 @@ fn retrieve_log_entries(
315
316
let new_entries = match file_index.find_log_entries(file, &file_params) {
317
Ok(entries) => entries,
317
- Err(_) => continue, // Skip files that fail to read
318
+ Err(e) => {
319
+ warn!(file = file.path(), "failed to retrieve log entries: {e}");
320
+ continue;
321
+ }
322
};
323
324
if new_entries.is_empty() {
src/crates/journal-index/src/file_index.rs
+6
-2
@@ -410,8 +410,12 @@ fn get_entry_timestamp(
410
) -> Result<u64> {
411
// Try to read the source timestamp field if specified
412
if let Some(field_name) = source_timestamp_field {
413
- if let Ok(timestamp) = get_timestamp_field(journal_file, field_name, entry_offset) {
414
- return Ok(timestamp);
413
+ match get_timestamp_field(journal_file, field_name, entry_offset) {
414
+ Ok(timestamp) => return Ok(timestamp),
415
+ Err(IndexError::MissingFieldName) => {
416
+ // Field not found, fall back to realtime timestamp
417
+ }
418
+ Err(e) => return Err(e),
419
}
420
}
421