This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new f4e22922dec4 CAMEL-26107: camel-wal - replay the unconfirmed entries 
of the previous run after a restart (#28638)
f4e22922dec4 is described below

commit f4e22922dec4cae27577c41639ea56b5ddd0dc45
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Oct 29 13:32:43 2026 +0530

    CAMEL-26107: camel-wal - replay the unconfirmed entries of the previous run 
after a restart (#28638)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/component/wal/EntryInfo.java  |  11 +
 .../org/apache/camel/component/wal/LogReader.java  |  55 +-
 .../org/apache/camel/component/wal/LogWriter.java  |   7 +
 .../apache/camel/component/wal/TransactionLog.java |  54 +-
 .../component/wal/WriteAheadResumeStrategy.java    | 243 ++++++---
 .../component/wal/LogReaderEntryPositionTest.java  | 105 ++++
 .../LogWriterUpdateAfterRollOverPositionTest.java  |  18 +
 .../camel/component/wal/TransactionLogTest.java    |  72 ++-
 .../wal/WriteAheadResumeStrategyRestartTest.java   | 587 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  21 +
 10 files changed, 1022 insertions(+), 151 deletions(-)

diff --git 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/EntryInfo.java
 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/EntryInfo.java
index c4fd076b0393..591ce2d63bfa 100644
--- 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/EntryInfo.java
+++ 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/EntryInfo.java
@@ -46,6 +46,17 @@ public class EntryInfo {
             return layerInfo;
         }
 
+        /**
+         * The position where the entry was written when this information was 
created. A roll-over can write the entry
+         * again at another position later; an update of its state writes 
where the entry is then, not at this position.
+         *
+         * @return the position where the entry was written when this 
information was created
+         */
+        @Override
+        public long getPosition() {
+            return super.getPosition();
+        }
+
     }
 
     /**
diff --git 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/LogReader.java
 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/LogReader.java
index 24a43c4b9881..ebdc6275eb69 100644
--- 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/LogReader.java
+++ 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/LogReader.java
@@ -92,6 +92,10 @@ public class LogReader implements AutoCloseable {
         LOG.trace("Read {} bytes from the file channel", bytesRead);
         ioBuffer.flip();
 
+        if (!fill(Header.BYTES)) {
+            throw new IllegalArgumentException("The file does not contain a 
valid header");
+        }
+
         byte[] name = new byte[Header.FORMAT_NAME_SIZE];
         ioBuffer.get(name, 0, Header.FORMAT_NAME_SIZE);
         LOG.trace("File format name: '{}'", new String(name));
@@ -105,8 +109,10 @@ public class LogReader implements AutoCloseable {
     /**
      * Read an entry from the file.
      *
-     * @return             A log entry from the file or null when reaching the 
end-of-file or if the file is empty
-     * @throws IOException if unable to read the entry
+     * @return                        A log entry from the file or null when 
reaching the end-of-file or if the file is
+     *                                empty
+     * @throws IOException            if unable to read the entry
+     * @throws InvalidRecordException if the entry is incomplete (i.e.: the 
file ends within it) or malformed
      */
     public PersistedLogEntry readEntry() throws IOException {
         if (header == null) {
@@ -137,10 +143,12 @@ public class LogReader implements AutoCloseable {
      * @throws IOException if unable to read the entry
      */
     private PersistedLogEntry doReadEntry() throws IOException {
-        if (ioBuffer.remaining() < Integer.BYTES) {
-            if (!reload()) {
-                return null;
-            }
+        // the position of the record in the file: the channel is ahead of the 
buffer by the bytes not read from it yet
+        final long recordPosition = fileChannel.position() - 
ioBuffer.remaining();
+
+        // there is at least one byte of the record in the buffer
+        if (!fill(Integer.BYTES)) {
+            throw new InvalidRecordException("A record is incomplete or 
malformed");
         }
 
         int state = ioBuffer.getInt();
@@ -148,7 +156,7 @@ public class LogReader implements AutoCloseable {
         Slot keySlot = readSlot();
         Slot valueSlot = readSlot();
 
-        EntryInfo entryInfo = 
EntryInfo.createForPersisted(fileChannel.position());
+        EntryInfo entryInfo = EntryInfo.createForPersisted(recordPosition);
 
         return new PersistedLogEntry(
                 entryInfo, LogEntry.EntryState.fromInt(state), 
keySlot.metadata, keySlot.data,
@@ -165,22 +173,22 @@ public class LogReader implements AutoCloseable {
         Slot slot = new Slot();
 
         // The buffer needs to have enough space for the metadata and length.
-        if (ioBuffer.remaining() < (Integer.BYTES * 2)) {
-            if (!reload()) {
-                throw new InvalidRecordException("A data slot within a record 
is incomplete or malformed");
-            }
+        if (!fill(Integer.BYTES * 2)) {
+            throw new InvalidRecordException("A data slot within a record is 
incomplete or malformed");
         }
         slot.metadata = ioBuffer.getInt();
         slot.length = ioBuffer.getInt();
 
+        if (slot.length < 0) {
+            throw new InvalidRecordException("A data slot within a record has 
an invalid length: " + slot.length);
+        }
+
         if (ioBuffer.capacity() < slot.length) {
             throw new BufferTooSmallException(ioBuffer.capacity(), 
slot.length);
         }
 
-        if (ioBuffer.remaining() < slot.length) {
-            if (!reload()) {
-                throw new InvalidRecordException("A data slot within a record 
is incomplete or malformed");
-            }
+        if (!fill(slot.length)) {
+            throw new InvalidRecordException("A data slot within a record is 
incomplete or malformed");
         }
 
         slot.data = new byte[slot.length];
@@ -189,6 +197,23 @@ public class LogReader implements AutoCloseable {
         return slot;
     }
 
+    /**
+     * Makes sure that the intermediate buffer holds at least the given number 
of bytes, reading more from the file if
+     * needed
+     *
+     * @param  bytes       the number of bytes needed
+     * @return             true if the buffer holds them or false if the file 
ends before
+     * @throws IOException in case of lower-level I/O errors
+     */
+    private boolean fill(int bytes) throws IOException {
+        while (ioBuffer.remaining() < bytes) {
+            if (!reload()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     /**
      * Reloads data into the intermediate buffer, compacting it on the process
      *
diff --git 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/LogWriter.java
 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/LogWriter.java
index bcbc8e887b11..ccb927759205 100644
--- 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/LogWriter.java
+++ 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/LogWriter.java
@@ -267,6 +267,13 @@ public final class LogWriter implements AutoCloseable {
             final TransactionLog.EntryContainer container = 
transactionLog.update(layerInfo, state);
 
             if (container != null) {
+                if (container.position < 0) {
+                    // the entry was never written, so there is nothing to 
update in the file. Defensive: the layer
+                    // information of an entry whose append failed is not 
given to a caller
+                    LOG.warn("Not updating the state of a record that was not 
written to the transaction log: {}",
+                            layerInfo);
+                    return;
+                }
                 // not the position of the entry info: a roll-over may have 
written the entry again elsewhere
                 persist(layerInfo, container.logEntry, container.position);
             }
diff --git 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/TransactionLog.java
 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/TransactionLog.java
index b647c3af878d..d885cd844476 100644
--- 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/TransactionLog.java
+++ 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/TransactionLog.java
@@ -74,8 +74,8 @@ class TransactionLog {
      * it's entry.
      */
     static class EntryContainer {
-        LayerInfo layerInfo;
-        LogEntry logEntry;
+        final LayerInfo layerInfo;
+        final LogEntry logEntry;
         /**
          * The current position of the entry in the file: a roll-over writes 
the entries of the previous layer again,
          * after the newer ones
@@ -128,42 +128,20 @@ class TransactionLog {
     }
 
     /**
-     * Given the information about an entry, it determines whether it can be 
updated or not
+     * Determines whether the entry with the given layer information can be 
updated: its slot in the buffer still holds
+     * it, so no entry of a newer layer has replaced it. The layer information 
of an entry of another transaction log,
+     * such as the log of a writer that was closed when the route stopped, is 
never in a slot of this one.
      *
-     * @param  transactionLogLayer the layer in the transaction log
-     * @param  transactionLogIndex the index in the transaction log
-     * @param  entryInfoLayer      the layer for the entry
-     * @param  entryInfoIndex      the index for the entry
-     * @return                     true if it can be updated or false otherwise
-     */
-    static boolean canUpdate(int transactionLogLayer, int transactionLogIndex, 
int entryInfoLayer, int entryInfoIndex) {
-        if (transactionLogLayer == entryInfoLayer) {
-            // Must make sure to not update beyond the current index
-            if (transactionLogIndex >= entryInfoIndex) {
-                return true;
-            }
-        }
-
-        // The current layer has replaced the entries before the current index 
(the index of the next entry to add).
-        // The entries from there on are still those of the previous layer, 
and those of older layers are all gone.
-        if (transactionLogLayer == entryInfoLayer + 1) {
-            if (transactionLogIndex <= entryInfoIndex) {
-                return true;
-            }
-        }
-
-        return false;
-    }
-
-    /**
-     * Given the layer information for an entry, it determines if it can be 
updated via
-     * {@link TransactionLog#canUpdate(int, int, int, int)}
-     *
-     * @param  layerInfo the layer information
+     * @param  layerInfo the layer information of the entry, as {@link 
#add(LogEntry)} created it
      * @return           true if it can be updated or false otherwise
      */
     private boolean canUpdate(LayerInfo layerInfo) {
-        return canUpdate(currentLayer, currentIndex, layerInfo.getLayer(), 
layerInfo.getIndex());
+        final int index = layerInfo.getIndex();
+        if (index < 0 || index >= logEntries.length) {
+            return false;
+        }
+        final EntryContainer container = logEntries[index];
+        return container != null && container.layerInfo == layerInfo;
     }
 
     /**
@@ -171,7 +149,8 @@ class TransactionLog {
      *
      * @param  layerInfo the layer information for the entry
      * @param  state     the state to update the entry to
-     * @return           the container of the updated entry or null if the 
record was rolled-over and discarded
+     * @return           the container of the updated entry or null if the 
record was rolled-over and discarded (or
+     *                   belongs to another transaction log, such as the log 
of a writer closed when the route stopped)
      */
     public EntryContainer update(LayerInfo layerInfo, LogEntry.EntryState 
state) {
         if (layerInfo == null) {
@@ -192,12 +171,13 @@ class TransactionLog {
             return container;
         } else {
             if (LOG.isDebugEnabled()) {
-                LOG.debug("Discarded a record because it has been rolled-over. 
Record layer: {}", layerInfo);
+                LOG.debug("Discarded a record because it has been rolled-over, 
or it is not in this log. Record layer: {}",
+                        layerInfo);
             }
 
             if (state == LogEntry.EntryState.FAILED) {
                 LOG.warn(
-                        "An update of failed record was discarded because it 
has been rolled over (it may have taken too long to report back)");
+                        "An update of failed record was discarded because it 
has been rolled over (it may have taken too long to report back), or because it 
belongs to the log of a previous start of the route");
             }
         }
 
diff --git 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/WriteAheadResumeStrategy.java
 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/WriteAheadResumeStrategy.java
index f184a0b64934..d08d133d1a85 100644
--- 
a/components/camel-wal/src/main/java/org/apache/camel/component/wal/WriteAheadResumeStrategy.java
+++ 
b/components/camel-wal/src/main/java/org/apache/camel/component/wal/WriteAheadResumeStrategy.java
@@ -20,11 +20,18 @@ package org.apache.camel.component.wal;
 import java.io.File;
 import java.io.IOException;
 import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ScheduledExecutorService;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.CamelContextAware;
 import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.component.wal.exceptions.BufferTooSmallException;
+import org.apache.camel.component.wal.exceptions.InvalidRecordException;
 import org.apache.camel.resume.Deserializable;
 import org.apache.camel.resume.Offset;
 import org.apache.camel.resume.OffsetKey;
@@ -70,18 +77,34 @@ public class WriteAheadResumeStrategy implements 
ResumeStrategy, CamelContextAwa
         }
     }
 
+    /**
+     * An entry of the previous run that was not confirmed by the delegate 
resume strategy, and where it is in the new
+     * transaction log
+     */
+    private record UnconfirmedEntry(LogEntry logEntry, 
EntryInfo.CachedEntryInfo entryInfo) {
+    }
+
+    /**
+     * The key of a log entry: its serialized key and its metadata (the type 
of the key)
+     */
+    private record EntryKey(int keyMetadata, ByteBuffer key) {
+    }
+
     private static final Logger LOG = 
LoggerFactory.getLogger(WriteAheadResumeStrategy.class);
     private File logFile;
     private LogWriter logWriter;
+    private List<UnconfirmedEntry> unconfirmedEntries = List.of();
     private ResumeStrategy resumeStrategy;
     private WriteAheadResumeStrategyConfiguration resumeStrategyConfiguration;
     private CamelContext camelContext;
+    // the number of entries the log keeps before it rolls over
+    private final int maxRecordCount;
 
     /**
      * Creates a new write-ahead resume strategy
      */
     public WriteAheadResumeStrategy() {
-
+        this.maxRecordCount = LogWriter.DEFAULT_CAPACITY;
     }
 
     /**
@@ -90,7 +113,16 @@ public class WriteAheadResumeStrategy implements 
ResumeStrategy, CamelContextAwa
      * @param resumeStrategyConfiguration the configuration to use for this 
strategy instance
      */
     public WriteAheadResumeStrategy(WriteAheadResumeStrategyConfiguration 
resumeStrategyConfiguration) {
+        this(resumeStrategyConfiguration, LogWriter.DEFAULT_CAPACITY);
+    }
+
+    /**
+     * Creates a new write-ahead resume strategy whose log keeps the given 
number of entries before it rolls over
+     * (visible for testing)
+     */
+    WriteAheadResumeStrategy(WriteAheadResumeStrategyConfiguration 
resumeStrategyConfiguration, int maxRecordCount) {
         this.resumeStrategyConfiguration = resumeStrategyConfiguration;
+        this.maxRecordCount = maxRecordCount;
     }
 
     @Override
@@ -143,29 +175,6 @@ public class WriteAheadResumeStrategy implements 
ResumeStrategy, CamelContextAwa
         }
     }
 
-    /**
-     * Handles the result of an offset update for persisted entries (i.e.: 
those already saved to permanent storage)
-     *
-     * @param entry the information about the entry that was updated
-     * @param t     a instance of any throwable class that was thrown by the 
delegate resume strategy during update, if
-     *              none, then can be null
-     */
-    private void handleResult(PersistedLogEntry entry, Throwable t) {
-        try {
-            if (t == null) {
-                logWriter.updateState(entry, LogEntry.EntryState.PROCESSED);
-            } else {
-                logWriter.updateState(entry, LogEntry.EntryState.FAILED);
-            }
-        } catch (IOException e) {
-            if (t == null) {
-                LOG.error("Unable to update state: {}", e.getMessage(), e);
-            } else {
-                LOG.error("Unable to mark the record as failed: {}", 
e.getMessage(), e);
-            }
-        }
-    }
-
     @Override
     public void updateLastOffset(OffsetKey<?> offsetKey, Offset<?> 
offsetValue) throws Exception {
         updateLastOffset(offsetKey, offsetValue, null);
@@ -224,30 +233,6 @@ public class WriteAheadResumeStrategy implements 
ResumeStrategy, CamelContextAwa
         }
     }
 
-    /**
-     * Tries to update the offset in the delegate strategy, ensuring the entry 
on log reflects the success or failure of
-     * the update request
-     *
-     * @param  offsetKey      the offset key to update
-     * @param  offsetValue    the offset value to update
-     * @param  entry          the entry being updated
-     * @param  updateCallBack a callback to be executed after the updated has 
occurred (null if not available)
-     * @throws Exception
-     */
-    private void tryUpdateDelegate(
-            OffsetKey<?> offsetKey, Offset<?> offsetValue, PersistedLogEntry 
entry, UpdateCallBack updateCallBack)
-            throws Exception {
-        try {
-            UpdateCallBack delegateCallback = resolveUpdateCallBack(entry, 
updateCallBack);
-
-            resumeStrategy.updateLastOffset(offsetKey, offsetValue, 
delegateCallback);
-        } catch (Throwable throwable) {
-            logWriter.updateState(entry, LogEntry.EntryState.FAILED);
-
-            throw throwable;
-        }
-    }
-
     private UpdateCallBack resolveUpdateCallBack(EntryInfo.CachedEntryInfo 
entryInfo, UpdateCallBack updateCallBack) {
         if (updateCallBack == null) {
             return t -> handleResult(entryInfo, t);
@@ -256,51 +241,133 @@ public class WriteAheadResumeStrategy implements 
ResumeStrategy, CamelContextAwa
         }
     }
 
-    private UpdateCallBack resolveUpdateCallBack(PersistedLogEntry entry, 
UpdateCallBack updateCallBack) {
-        if (updateCallBack == null) {
-            return t -> handleResult(entry, t);
-        } else {
-            return new DelegateCallback(updateCallBack, t -> 
handleResult(entry, t));
-        }
-    }
-
     @Override
     public void loadCache() throws Exception {
         LOG.debug("Loading cache for the delegate strategy");
         resumeStrategy.loadCache();
         LOG.debug("Done loading cache for the delegate strategy");
 
+        final List<UnconfirmedEntry> entries = unconfirmedEntries;
+        unconfirmedEntries = List.of();
+        if (entries.isEmpty()) {
+            return;
+        }
+
+        final ResumeAdapter adapter = resumeStrategy.getAdapter();
+        if (!(adapter instanceof Deserializable deserializable)) {
+            LOG.warn("Not replaying the {} unconfirmed entries of the 
transaction log {}: the resume adapter {} cannot "
+                     + "deserialize them. They are kept in the log",
+                    entries.size(), logFile, adapter);
+            return;
+        }
+
+        LOG.info("Replaying {} unconfirmed entries of the transaction log {}", 
entries.size(), logFile);
+        for (UnconfirmedEntry entry : entries) {
+            // as for a new offset, a failure is logged and does not stop the 
others (nor the start of the route)
+            try {
+                replay(deserializable, entry);
+            } catch (Exception e) {
+                LOG.warn("Unable to replay an unconfirmed entry of the 
transaction log {}: it is kept in the log and "
+                         + "replayed again on the next start. Reason: {}",
+                        logFile, e.toString(), e);
+            }
+        }
+        LOG.trace("Finished replaying the unconfirmed entries");
+    }
+
+    private void replay(Deserializable deserializable, UnconfirmedEntry entry) 
throws Exception {
+        Object oKey = 
deserializable.deserializeKey(ByteBuffer.wrap(entry.logEntry().getKey()));
+        Object value = 
deserializable.deserializeValue(ByteBuffer.wrap(entry.logEntry().getValue()));
+        if (oKey == null || value == null) {
+            LOG.warn("Not replaying an unconfirmed entry of the transaction 
log {}: the resume adapter cannot "
+                     + "deserialize its key or value. It is kept in the log",
+                    logFile);
+            return;
+        }
+
+        tryUpdateDelegate(OffsetKeys.of(oKey), Offsets.of(value), 
entry.entryInfo(), null);
+    }
+
+    /**
+     * Reads the entries that were not confirmed by the delegate resume 
strategy from the transaction log of the
+     * previous run. As the delegate keeps the last offset of each key, only 
the last entry of each key in the log
+     * counts: it is unconfirmed if it is in state NEW or FAILED. An earlier 
entry of the same key is superseded, so a
+     * FAILED offset does not replace a later one that the delegate confirmed. 
The log ends where a record is incomplete
+     * or invalid (i.e.: the last one, when the system crashed while writing 
it): the entries before it are kept.
+     * <p/>
+     * A log that holds as many entries as it keeps before it rolls over may 
have rolled over. Then the entries of the
+     * newer layer come first in the file, and the file has no layer 
information, so the last entry of a key in the file
+     * may be an older offset of the key. Such a log is not replayed: 
replaying an older offset would make the consumer
+     * resume from it. This does not cover a log that rolled over and was cut 
short while an append rewrote it (the
+     * system crashed then): it holds fewer entries, the newer ones first.
+     *
+     * @param  logFile        the transaction log file, which may not exist
+     * @param  maxRecordCount the number of entries the log keeps before it 
rolls over
+     * @return                the unconfirmed entries, in the order of the log
+     */
+    private static List<LogEntry> readUnconfirmedEntries(File logFile, int 
maxRecordCount) {
+        final Map<EntryKey, LogEntry> lastEntries = new LinkedHashMap<>();
+        if (!logFile.isFile()) {
+            return List.of();
+        }
+
+        int count = 0;
+
         try (LogReader reader = new LogReader(logFile)) {
+            final Header header = reader.getHeader();
+            if (header == null) {
+                return List.of();
+            }
+
+            if (!Header.FORMAT_NAME.equals(header.getFormatName().trim())
+                    || header.getFileVersion() != Header.CURRENT_FILE_VERSION) 
{
+                LOG.warn("Not replaying the transaction log {}: it has an 
unknown format '{}' version {}, and a new log "
+                         + "replaces it",
+                        logFile, header.getFormatName().trim(), 
header.getFileVersion());
+                return List.of();
+            }
 
-            int updatedCount = 0;
-            LOG.trace("Starting to read log entries");
             PersistedLogEntry logEntry;
-            do {
-                logEntry = reader.readEntry();
-                if (logEntry != null) {
-                    final LogEntry.EntryState entryState = 
logEntry.getEntryState();
-                    if (entryState == LogEntry.EntryState.NEW || entryState == 
LogEntry.EntryState.FAILED) {
-                        final ResumeAdapter adapter = 
resumeStrategy.getAdapter();
-
-                        if (adapter instanceof Deserializable) {
-                            Deserializable deserializable = (Deserializable) 
adapter;
-
-                            Object oKey = 
deserializable.deserializeKey(ByteBuffer.wrap(logEntry.getKey()));
-                            Object value = 
deserializable.deserializeValue(ByteBuffer.wrap(logEntry.getValue()));
-
-                            tryUpdateDelegate(OffsetKeys.of(oKey), 
Offsets.of(value), logEntry, null);
-                            updatedCount++;
-                        }
-                    }
-                }
-
-            } while (logEntry != null);
-            LOG.trace("Finished reading log entries");
-
-            if (updatedCount == 0) {
-                logWriter.reset();
+            while ((logEntry = reader.readEntry()) != null) {
+                count++;
+                final EntryKey key = new EntryKey(logEntry.getKeyMetadata(), 
ByteBuffer.wrap(logEntry.getKey()));
+                // keep the keys in the order of their last entry
+                lastEntries.remove(key);
+                lastEntries.put(key, logEntry);
+            }
+        } catch (InvalidRecordException | IllegalArgumentException | 
BufferTooSmallException e) {
+            LOG.warn("Stopped reading the transaction log {} at an incomplete 
or invalid record, the rest of the log is "
+                     + "skipped: the {} entries before it are read. Reason: 
{}",
+                    logFile, count, e.toString());
+        } catch (IOException e) {
+            // such as no permission to read the file: not a record of the 
log, but the log cannot be read further
+            LOG.warn("Cannot read the transaction log {}, the rest of the log 
is skipped: the {} entries before the "
+                     + "error are read. Reason: {}",
+                    logFile, count, e.toString());
+            LOG.debug("Cannot read the transaction log {}", logFile, e);
+        }
+
+        final List<LogEntry> unconfirmed = unconfirmed(lastEntries.values());
+        if (count >= maxRecordCount && !unconfirmed.isEmpty()) {
+            LOG.warn("Not replaying the {} unconfirmed entries of the 
transaction log {}: it holds {} entries, so it may "
+                     + "have rolled over, and then the order of its entries is 
not the order of the offsets. A new log "
+                     + "replaces it, so these entries are discarded",
+                    unconfirmed.size(), logFile, count);
+            return List.of();
+        }
+        return unconfirmed;
+    }
+
+    private static List<LogEntry> unconfirmed(Collection<LogEntry> entries) {
+        final List<LogEntry> unconfirmed = new ArrayList<>();
+        for (LogEntry entry : entries) {
+            final LogEntry.EntryState entryState = entry.getEntryState();
+            if (entryState == LogEntry.EntryState.NEW || entryState == 
LogEntry.EntryState.FAILED) {
+                unconfirmed.add(new LogEntry(
+                        entryState, entry.getKeyMetadata(), entry.getKey(), 
entry.getValueMetadata(), entry.getValue()));
             }
         }
+        return unconfirmed;
     }
 
     @Override
@@ -309,13 +376,23 @@ public class WriteAheadResumeStrategy implements 
ResumeStrategy, CamelContextAwa
             this.logFile = resumeStrategyConfiguration.getLogFile();
             this.resumeStrategy = 
resumeStrategyConfiguration.getDelegateResumeStrategy();
 
+            // the writer starts a new log, so read the entries of the 
previous run first
+            final List<LogEntry> entries = readUnconfirmedEntries(logFile, 
maxRecordCount);
+
             final ScheduledExecutorService executorService = 
camelContext.getExecutorServiceManager()
                     .newScheduledThreadPool(this, 
"SingleNodeKafkaResumeStrategy", 1);
 
             DefaultLogSupervisor flushPolicy = new DefaultLogSupervisor(
                     resumeStrategyConfiguration.getSupervisorInterval(),
                     executorService);
-            logWriter = new LogWriter(logFile, flushPolicy);
+            logWriter = new LogWriter(logFile, flushPolicy, maxRecordCount);
+
+            // keep them in the new log until loadCache() replays them, in 
case the system crashes again before
+            final List<UnconfirmedEntry> carried = new 
ArrayList<>(entries.size());
+            for (LogEntry entry : entries) {
+                carried.add(new UnconfirmedEntry(entry, 
logWriter.append(entry)));
+            }
+            unconfirmedEntries = carried;
         } catch (Exception e) {
             throw new RuntimeCamelException(e);
         }
diff --git 
a/components/camel-wal/src/test/java/org/apache/camel/component/wal/LogReaderEntryPositionTest.java
 
b/components/camel-wal/src/test/java/org/apache/camel/component/wal/LogReaderEntryPositionTest.java
new file mode 100644
index 000000000000..651f651c6652
--- /dev/null
+++ 
b/components/camel-wal/src/test/java/org/apache/camel/component/wal/LogReaderEntryPositionTest.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.wal;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The reader gives each entry the position where its record starts in the 
file, so that
+ * {@link LogWriter#updateState(PersistedLogEntry, LogEntry.EntryState)} 
writes the new state over that record. The
+ * records have different sizes, and the reader's buffer is small, so that it 
reads the file in several parts.
+ */
+class LogReaderEntryPositionTest {
+
+    @TempDir
+    File testDir;
+
+    private static LogEntry entry(String key) {
+        return new LogEntry(
+                LogEntry.EntryState.NEW, 0, 
key.getBytes(StandardCharsets.UTF_8), 0,
+                ("offset-of-" + key).getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static List<PersistedLogEntry> readAll(File file, int capacity) 
throws Exception {
+        List<PersistedLogEntry> entries = new ArrayList<>();
+        try (LogReader reader = new LogReader(file, capacity)) {
+            PersistedLogEntry entry;
+            while ((entry = reader.readEntry()) != null) {
+                entries.add(entry);
+            }
+        }
+        return entries;
+    }
+
+    private static String describe(List<PersistedLogEntry> entries) {
+        List<String> answer = new ArrayList<>();
+        for (PersistedLogEntry entry : entries) {
+            String key = new String(entry.getKey(), StandardCharsets.UTF_8);
+            assertEquals("offset-of-" + key, new String(entry.getValue(), 
StandardCharsets.UTF_8),
+                    "value of record " + key + " is corrupted");
+            answer.add(key + "=" + entry.getEntryState());
+        }
+        return answer.toString();
+    }
+
+    @Test
+    void entriesHaveThePositionOfTheirRecord() throws Exception {
+        File file = new File(testDir, "wal.data");
+        List<Long> positions = new ArrayList<>();
+        try (LogWriter writer = new LogWriter(file, new 
DefaultLogSupervisor(100_000))) {
+            for (String key : List.of("a", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 
"cc", "dddddddddddddddd", "e")) {
+                positions.add(writer.append(entry(key)).getPosition());
+            }
+        }
+
+        // 64 bytes: the buffer holds one or two of the shorter records at a 
time, and the 94 bytes record of "bbb..."
+        // spans two reads of the buffer
+        List<PersistedLogEntry> entries = readAll(file, 64);
+        List<Long> read = new ArrayList<>();
+        for (PersistedLogEntry entry : entries) {
+            read.add(entry.getEntryInfo().getPosition());
+        }
+        assertEquals(positions, read);
+    }
+
+    @Test
+    void updateOfAReadEntryChangesThatRecordOnly() throws Exception {
+        File file = new File(testDir, "wal.data");
+        try (LogWriter writer = new LogWriter(file, new 
DefaultLogSupervisor(100_000))) {
+            for (String key : List.of("a", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 
"cc", "dddddddddddddddd")) {
+                writer.append(entry(key));
+            }
+            writer.flush();
+
+            List<PersistedLogEntry> entries = readAll(file, 64);
+            writer.updateState(entries.get(1), LogEntry.EntryState.PROCESSED);
+            writer.updateState(entries.get(2), LogEntry.EntryState.FAILED);
+            writer.flush();
+        }
+
+        assertEquals("[a=NEW, bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=PROCESSED, 
cc=FAILED, dddddddddddddddd=NEW]",
+                describe(readAll(file, LogReader.DEFAULT_CAPACITY)));
+    }
+}
diff --git 
a/components/camel-wal/src/test/java/org/apache/camel/component/wal/LogWriterUpdateAfterRollOverPositionTest.java
 
b/components/camel-wal/src/test/java/org/apache/camel/component/wal/LogWriterUpdateAfterRollOverPositionTest.java
index beef37891f39..7635bbead06f 100644
--- 
a/components/camel-wal/src/test/java/org/apache/camel/component/wal/LogWriterUpdateAfterRollOverPositionTest.java
+++ 
b/components/camel-wal/src/test/java/org/apache/camel/component/wal/LogWriterUpdateAfterRollOverPositionTest.java
@@ -115,6 +115,24 @@ class LogWriterUpdateAfterRollOverPositionTest {
         assertEquals("{d=NEW, e=NEW, cc=NEW}", readAll(file).toString());
     }
 
+    @Test
+    void shorterRecordAtTheEndOfALayerKeepsFileReadable() throws Exception {
+        File file = new File(testDir, "wal.data");
+        try (LogWriter writer = new LogWriter(file, new 
DefaultLogSupervisor(100_000), 3)) {
+            writer.append(entry("aa"));
+            writer.append(entry("bb"));
+            writer.append(entry("cccccccccccccccc"));
+            // roll-over: "d" and "e" replace "aa" and "bb"
+            writer.append(entry("d"));
+            writer.append(entry("e"));
+            // "f" replaces the longer "cccccccccccccccc", the last record of 
the file: nothing is written after it
+            writer.append(entry("f"));
+            writer.flush();
+        }
+
+        assertEquals("{d=NEW, e=NEW, f=NEW}", readAll(file).toString());
+    }
+
     @Test
     void updateOfEntryTwoLayersBehindDoesNotTouchNewerEntry() throws Exception 
{
         File file = new File(testDir, "wal.data");
diff --git 
a/components/camel-wal/src/test/java/org/apache/camel/component/wal/TransactionLogTest.java
 
b/components/camel-wal/src/test/java/org/apache/camel/component/wal/TransactionLogTest.java
index 111dbbb32246..4939ea803446 100644
--- 
a/components/camel-wal/src/test/java/org/apache/camel/component/wal/TransactionLogTest.java
+++ 
b/components/camel-wal/src/test/java/org/apache/camel/component/wal/TransactionLogTest.java
@@ -14,40 +14,80 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package org.apache.camel.component.wal;
 
+import java.nio.charset.StandardCharsets;
+
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+class TransactionLogTest {
+
+    private static LogEntry entry(String key) {
+        return new LogEntry(LogEntry.EntryState.NEW, 0, 
key.getBytes(StandardCharsets.UTF_8), 0, new byte[] { 1 });
+    }
+
+    private static TransactionLog.EntryContainer[] add(TransactionLog log, int 
count) {
+        TransactionLog.EntryContainer[] containers = new 
TransactionLog.EntryContainer[count];
+        for (int i = 0; i < count; i++) {
+            containers[i] = log.add(entry("record-" + i));
+        }
+        return containers;
+    }
 
-public class TransactionLogTest {
     @DisplayName("Tests that can update records on the same layer")
     @Test
     void testCanUpdateSameLayer() {
-        assertTrue(TransactionLog.canUpdate(0, 10, 0, 1));
-        assertTrue(TransactionLog.canUpdate(0, 10, 0, 10));
-        assertFalse(TransactionLog.canUpdate(0, 10, 0, 11));
+        TransactionLog log = new TransactionLog(10);
+        TransactionLog.EntryContainer[] containers = add(log, 3);
+
+        assertSame(containers[0], log.update(containers[0].layerInfo, 
LogEntry.EntryState.PROCESSED));
+        assertSame(containers[2], log.update(containers[2].layerInfo, 
LogEntry.EntryState.FAILED));
     }
 
     @DisplayName("Tests that prevent updating a record that has been 
rolled-over")
     @Test
     void testCannotUpdateRolledOverRecord() {
-        assertFalse(TransactionLog.canUpdate(1, 3, 0, 1));
-        assertFalse(TransactionLog.canUpdate(1, 3, 0, 2));
-        // two layers behind: the previous layer has replaced it already
-        assertFalse(TransactionLog.canUpdate(2, 3, 0, 5));
+        TransactionLog log = new TransactionLog(3);
+        TransactionLog.EntryContainer[] containers = add(log, 5);
+
+        // records 3 and 4 (layer 1) replaced records 0 and 1 (layer 0)
+        assertNull(log.update(containers[0].layerInfo, 
LogEntry.EntryState.PROCESSED));
+        assertNull(log.update(containers[1].layerInfo, 
LogEntry.EntryState.PROCESSED));
+
+        // two layers behind: record 7 (layer 2) replaced record 4 (layer 1), 
which had replaced record 1 (layer 0)
+        TransactionLog.EntryContainer[] next = add(log, 3);
+        assertNull(log.update(containers[1].layerInfo, 
LogEntry.EntryState.PROCESSED));
+        assertNull(log.update(containers[4].layerInfo, 
LogEntry.EntryState.PROCESSED));
+        assertSame(next[2], log.update(next[2].layerInfo, 
LogEntry.EntryState.PROCESSED));
     }
 
     @DisplayName("Tests that can update records after a roll-over has started")
     @Test
     void testCanUpdateDifferentLayerWithRollOver() {
-        assertTrue(TransactionLog.canUpdate(1, 10, 0, 11));
-        assertTrue(TransactionLog.canUpdate(1, 10, 1, 10));
-        // the current index is the index of the next entry to add, so the 
entry there is not replaced yet
-        assertTrue(TransactionLog.canUpdate(1, 10, 0, 10));
-        assertTrue(TransactionLog.canUpdate(1, 3, 0, 3));
+        TransactionLog log = new TransactionLog(3);
+        TransactionLog.EntryContainer[] containers = add(log, 4);
+
+        // record 3 (layer 1) replaced record 0; record 1, the next to 
replace, and record 2 are still there
+        assertSame(containers[1], log.update(containers[1].layerInfo, 
LogEntry.EntryState.PROCESSED));
+        assertSame(containers[2], log.update(containers[2].layerInfo, 
LogEntry.EntryState.PROCESSED));
+        assertSame(containers[3], log.update(containers[3].layerInfo, 
LogEntry.EntryState.PROCESSED));
+    }
+
+    @DisplayName("Tests that a record of another transaction log is not 
updated in this one")
+    @Test
+    void testCannotUpdateRecordOfAnotherLog() {
+        // as after a restart of the route: the writer of the previous start 
had its own transaction log
+        TransactionLog previous = new TransactionLog(10);
+        TransactionLog.EntryContainer[] stale = add(previous, 2);
+        TransactionLog log = new TransactionLog(10);
+        TransactionLog.EntryContainer[] containers = add(log, 2);
+
+        assertNull(log.update(stale[1].layerInfo, 
LogEntry.EntryState.PROCESSED));
+        assertEquals(LogEntry.EntryState.NEW, 
containers[1].logEntry.getEntryState());
     }
 }
diff --git 
a/components/camel-wal/src/test/java/org/apache/camel/component/wal/WriteAheadResumeStrategyRestartTest.java
 
b/components/camel-wal/src/test/java/org/apache/camel/component/wal/WriteAheadResumeStrategyRestartTest.java
new file mode 100644
index 000000000000..dfe092d22940
--- /dev/null
+++ 
b/components/camel-wal/src/test/java/org/apache/camel/component/wal/WriteAheadResumeStrategyRestartTest.java
@@ -0,0 +1,587 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.wal;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.lang.reflect.Proxy;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executors;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.resume.Deserializable;
+import org.apache.camel.resume.Offset;
+import org.apache.camel.resume.OffsetKey;
+import org.apache.camel.resume.Resumable;
+import org.apache.camel.resume.ResumeAdapter;
+import org.apache.camel.resume.ResumeStrategy;
+import org.apache.camel.resume.ResumeStrategy.UpdateCallBack;
+import org.apache.camel.resume.ResumeStrategyConfiguration;
+import org.apache.camel.spi.ExecutorServiceManager;
+import org.apache.camel.support.resume.OffsetKeys;
+import org.apache.camel.support.resume.Offsets;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The entries that the delegate strategy did not confirm before the system 
stopped are replayed on the next start, in
+ * the order Camel calls the strategy: start() (from 
ResumableProcessor.doStart) and then loadCache() (from the
+ * consumer).
+ */
+class WriteAheadResumeStrategyRestartTest {
+
+    /**
+     * The size of a record with a key "key-N" (one digit) and a long value
+     */
+    private static final int RECORD_SIZE = 41;
+
+    @TempDir
+    File testDir;
+
+    private final List<WriteAheadResumeStrategy> running = new ArrayList<>();
+
+    static class Adapter implements ResumeAdapter, Deserializable {
+        @Override
+        public void resume() {
+        }
+
+        @Override
+        public boolean deserialize(ByteBuffer keyBuffer, ByteBuffer 
valueBuffer) {
+            return true;
+        }
+    }
+
+    /**
+     * A delegate strategy that records the replayed offsets and confirms 
them, except for the keys it fails
+     */
+    static class Delegate implements ResumeStrategy {
+        final List<String> replayed = new CopyOnWriteArrayList<>();
+        final Set<String> failingKeys;
+        final Adapter adapter = new Adapter();
+
+        Delegate(String... failingKeys) {
+            this.failingKeys = Set.of(failingKeys);
+        }
+
+        @Override
+        public void setAdapter(ResumeAdapter adapter) {
+        }
+
+        @Override
+        public ResumeAdapter getAdapter() {
+            return adapter;
+        }
+
+        @Override
+        public <T extends Resumable> void updateLastOffset(T offset) {
+        }
+
+        @Override
+        public <T extends Resumable> void updateLastOffset(T offset, 
UpdateCallBack updateCallBack) {
+        }
+
+        @Override
+        public void updateLastOffset(OffsetKey<?> offsetKey, Offset<?> 
offsetValue) {
+            updateLastOffset(offsetKey, offsetValue, null);
+        }
+
+        @Override
+        public void updateLastOffset(OffsetKey<?> offsetKey, Offset<?> offset, 
UpdateCallBack updateCallBack) {
+            replayed.add(offsetKey.getValue() + "=" + offset.getValue());
+            if (updateCallBack != null) {
+                
updateCallBack.onUpdate(failingKeys.contains(offsetKey.getValue())
+                        ? new IllegalStateException("the delegate failed") : 
null);
+            }
+        }
+
+        @Override
+        public void setResumeStrategyConfiguration(ResumeStrategyConfiguration 
resumeStrategyConfiguration) {
+        }
+
+        @Override
+        public ResumeStrategyConfiguration getResumeStrategyConfiguration() {
+            return null;
+        }
+
+        @Override
+        public void start() {
+        }
+
+        @Override
+        public void stop() {
+        }
+    }
+
+    /**
+     * A delegate strategy that never calls back, so the entries stay 
unconfirmed (as with the MongoDB strategy)
+     */
+    static class NeverConfirmingDelegate extends Delegate {
+        @Override
+        public void updateLastOffset(OffsetKey<?> offsetKey, Offset<?> offset, 
UpdateCallBack updateCallBack) {
+            replayed.add(offsetKey.getValue() + "=" + offset.getValue());
+        }
+    }
+
+    /**
+     * A delegate strategy that keeps the callbacks of some keys without 
calling them (as a delegate that confirms
+     * later, on another thread), and confirms the others
+     */
+    static class LateConfirmingDelegate extends Delegate {
+        final Set<String> lateKeys;
+        final List<UpdateCallBack> callbacks = new CopyOnWriteArrayList<>();
+
+        LateConfirmingDelegate(String... lateKeys) {
+            this.lateKeys = Set.of(lateKeys);
+        }
+
+        @Override
+        public void updateLastOffset(OffsetKey<?> offsetKey, Offset<?> offset, 
UpdateCallBack updateCallBack) {
+            if (lateKeys.contains(offsetKey.getValue())) {
+                replayed.add(offsetKey.getValue() + "=" + offset.getValue());
+                callbacks.add(updateCallBack);
+            } else {
+                super.updateLastOffset(offsetKey, offset, updateCallBack);
+            }
+        }
+    }
+
+    /**
+     * A delegate strategy that throws for one key (as when its storage is not 
available)
+     */
+    static class ThrowingDelegate extends Delegate {
+        private final String throwingKey;
+
+        ThrowingDelegate(String throwingKey) {
+            this.throwingKey = throwingKey;
+        }
+
+        @Override
+        public void updateLastOffset(OffsetKey<?> offsetKey, Offset<?> offset, 
UpdateCallBack updateCallBack) {
+            if (throwingKey.equals(offsetKey.getValue())) {
+                replayed.add(offsetKey.getValue() + "=" + offset.getValue());
+                throw new IllegalStateException("the delegate is not 
available");
+            }
+            super.updateLastOffset(offsetKey, offset, updateCallBack);
+        }
+    }
+
+    @AfterEach
+    void stopRunningStrategies() {
+        for (WriteAheadResumeStrategy strategy : List.copyOf(running)) {
+            stop(strategy);
+        }
+    }
+
+    private static LogEntry entry(LogEntry.EntryState state, String key, long 
value) {
+        return new LogEntry(
+                state, 0, OffsetKeys.of(key).serialize().array(), 0, 
Offsets.of(value).serialize().array());
+    }
+
+    /**
+     * Writes a log as a previous run would leave it
+     */
+    private File writeLog(LogEntry... entries) throws IOException {
+        return writeLog(LogWriter.DEFAULT_CAPACITY, entries);
+    }
+
+    /**
+     * Writes a log as a previous run would leave it, with a log that keeps 
the given number of entries before it rolls
+     * over
+     */
+    private File writeLog(int maxRecordCount, LogEntry... entries) throws 
IOException {
+        File logFile = new File(testDir, "wal.data");
+        try (LogWriter writer = new LogWriter(logFile, new 
DefaultLogSupervisor(100_000), maxRecordCount)) {
+            for (LogEntry entry : entries) {
+                writer.append(entry);
+            }
+        }
+        return logFile;
+    }
+
+    private File writeNewEntries(int count) throws IOException {
+        LogEntry[] entries = new LogEntry[count];
+        for (int i = 0; i < count; i++) {
+            entries[i] = entry(LogEntry.EntryState.NEW, "key-" + i, i);
+        }
+        return writeLog(entries);
+    }
+
+    private WriteAheadResumeStrategy start(File logFile, ResumeStrategy 
delegate) {
+        return start(logFile, delegate, LogWriter.DEFAULT_CAPACITY);
+    }
+
+    private WriteAheadResumeStrategy start(File logFile, ResumeStrategy 
delegate, int maxRecordCount) {
+        WriteAheadResumeStrategy strategy = new WriteAheadResumeStrategy(
+                WriteAheadResumeStrategyConfigurationBuilder.newBuilder()
+                        
.withLogFile(logFile).withDelegateResumeStrategy(delegate).build(),
+                maxRecordCount);
+
+        ExecutorServiceManager executorServiceManager = 
(ExecutorServiceManager) Proxy.newProxyInstance(
+                getClass().getClassLoader(), new Class<?>[] { 
ExecutorServiceManager.class },
+                (proxy, method, args) -> 
"newScheduledThreadPool".equals(method.getName())
+                        ? Executors.newSingleThreadScheduledExecutor() : null);
+        CamelContext camelContext = (CamelContext) Proxy.newProxyInstance(
+                getClass().getClassLoader(), new Class<?>[] { 
CamelContext.class },
+                (proxy, method, args) -> 
"getExecutorServiceManager".equals(method.getName())
+                        ? executorServiceManager : null);
+        strategy.setCamelContext(camelContext);
+
+        strategy.start();
+        running.add(strategy);
+        return strategy;
+    }
+
+    private WriteAheadResumeStrategy restart(File logFile, ResumeStrategy 
delegate) throws Exception {
+        WriteAheadResumeStrategy strategy = start(logFile, delegate);
+        strategy.loadCache();
+        return strategy;
+    }
+
+    private void stop(WriteAheadResumeStrategy strategy) {
+        running.remove(strategy);
+        strategy.stop();
+    }
+
+    /**
+     * Reads the log, as "key=STATE" for each entry
+     */
+    private static List<String> readLog(File logFile) throws IOException {
+        Adapter adapter = new Adapter();
+        List<String> entries = new ArrayList<>();
+        try (LogReader reader = new LogReader(logFile)) {
+            assertNotNull(reader.getHeader(), "the log has a header");
+            assertEquals(Header.FORMAT_NAME, 
reader.getHeader().getFormatName().trim());
+            assertEquals(Header.CURRENT_FILE_VERSION, 
reader.getHeader().getFileVersion());
+
+            PersistedLogEntry entry;
+            while ((entry = reader.readEntry()) != null) {
+                
entries.add(adapter.deserializeKey(ByteBuffer.wrap(entry.getKey())) + "=" + 
entry.getEntryState());
+            }
+        }
+        return entries;
+    }
+
+    private static List<LogEntry.EntryState> readStates(File logFile) throws 
IOException {
+        List<LogEntry.EntryState> states = new ArrayList<>();
+        try (LogReader reader = new LogReader(logFile)) {
+            PersistedLogEntry entry;
+            while ((entry = reader.readEntry()) != null) {
+                states.add(entry.getEntryState());
+            }
+        }
+        return states;
+    }
+
+    private static void writeIntAt(File file, long position, int value) throws 
IOException {
+        try (RandomAccessFile raf = new RandomAccessFile(file, "rw")) {
+            raf.seek(position);
+            raf.writeInt(value);
+        }
+    }
+
+    private static void truncate(File file, long length) throws IOException {
+        try (RandomAccessFile raf = new RandomAccessFile(file, "rw")) {
+            raf.setLength(length);
+        }
+    }
+
+    @Test
+    void entriesOfTheSameSerializedKeyWithAnotherKeyMetadataAreDifferentKeys() 
throws Exception {
+        byte[] key = OffsetKeys.of("key-0").serialize().array();
+        File logFile = writeLog(
+                new LogEntry(LogEntry.EntryState.NEW, 0, key, 0, 
Offsets.of(0L).serialize().array()),
+                new LogEntry(LogEntry.EntryState.NEW, 1, key, 0, 
Offsets.of(1L).serialize().array()),
+                new LogEntry(LogEntry.EntryState.NEW, 0, key, 0, 
Offsets.of(2L).serialize().array()));
+
+        Delegate delegate = new Delegate();
+        stop(restart(logFile, delegate));
+
+        // the last entry of each (key metadata, key), in the order of their 
last entry
+        assertEquals(List.of("key-0=1", "key-0=2"), delegate.replayed);
+    }
+
+    @Test
+    void restartReplaysTheNewAndFailedEntriesButNotTheProcessedOnes() throws 
Exception {
+        File logFile = writeLog(
+                entry(LogEntry.EntryState.NEW, "key-0", 0),
+                entry(LogEntry.EntryState.PROCESSED, "key-1", 1),
+                entry(LogEntry.EntryState.FAILED, "key-2", 2),
+                entry(LogEntry.EntryState.NEW, "key-3", 3));
+
+        Delegate delegate = new Delegate();
+        stop(restart(logFile, delegate));
+
+        assertEquals(List.of("key-0=0", "key-2=2", "key-3=3"), 
delegate.replayed);
+        assertEquals(List.of("key-0=PROCESSED", "key-2=PROCESSED", 
"key-3=PROCESSED"), readLog(logFile));
+
+        Delegate next = new Delegate();
+        stop(restart(logFile, next));
+        assertEquals(List.of(), next.replayed, "the confirmed entries are not 
replayed again");
+    }
+
+    @Test
+    void onlyTheLastOffsetOfEachKeyIsReplayed() throws Exception {
+        File logFile = writeLog(
+                entry(LogEntry.EntryState.FAILED, "key-0", 0),
+                entry(LogEntry.EntryState.PROCESSED, "key-0", 1),
+                entry(LogEntry.EntryState.NEW, "key-1", 2),
+                entry(LogEntry.EntryState.NEW, "key-1", 3),
+                entry(LogEntry.EntryState.PROCESSED, "key-2", 4),
+                entry(LogEntry.EntryState.FAILED, "key-2", 5));
+
+        Delegate delegate = new Delegate();
+        stop(restart(logFile, delegate));
+
+        // a failed offset does not replace a later offset of its key that the 
delegate confirmed
+        assertEquals(List.of("key-1=3", "key-2=5"), delegate.replayed);
+        assertEquals(List.of("key-1=PROCESSED", "key-2=PROCESSED"), 
readLog(logFile));
+    }
+
+    @Test
+    void entriesOfADelegateThatNeverConfirmsDoNotPileUpAcrossRestarts() throws 
Exception {
+        File logFile = new File(testDir, "wal.data");
+        WriteAheadResumeStrategy strategy = restart(logFile, new 
NeverConfirmingDelegate());
+        strategy.updateLastOffset(OffsetKeys.of("key-0"), Offsets.of(0L));
+        strategy.updateLastOffset(OffsetKeys.of("key-0"), Offsets.of(1L));
+        strategy.updateLastOffset(OffsetKeys.of("key-1"), Offsets.of(2L));
+        stop(strategy);
+
+        for (int i = 0; i < 2; i++) {
+            Delegate delegate = new NeverConfirmingDelegate();
+            stop(restart(logFile, delegate));
+
+            assertEquals(List.of("key-0=1", "key-1=2"), delegate.replayed);
+            assertEquals(List.of("key-0=NEW", "key-1=NEW"), readLog(logFile));
+        }
+    }
+
+    @Test
+    void entryThatCannotBeReplayedDoesNotStopTheOthersNorTheStart() throws 
Exception {
+        byte[] value = Offsets.of(7L).serialize().array();
+        File logFile = writeLog(
+                // a key too short for its type, and a key of an unknown type
+                new LogEntry(LogEntry.EntryState.NEW, 0, new byte[] { 1, 2 }, 
0, value),
+                new LogEntry(LogEntry.EntryState.NEW, 0, 
ByteBuffer.allocate(8).putInt(99).array(), 0, value),
+                entry(LogEntry.EntryState.NEW, "key-0", 0),
+                entry(LogEntry.EntryState.NEW, "key-1", 1));
+
+        Delegate delegate = new ThrowingDelegate("key-0");
+        stop(restart(logFile, delegate));
+
+        assertEquals(List.of("key-0=0", "key-1=1"), delegate.replayed);
+        assertEquals(List.of(LogEntry.EntryState.NEW, LogEntry.EntryState.NEW, 
LogEntry.EntryState.FAILED,
+                LogEntry.EntryState.PROCESSED), readStates(logFile));
+
+        Delegate next = new Delegate();
+        stop(restart(logFile, next));
+        assertEquals(List.of("key-0=0"), next.replayed);
+        assertEquals(List.of(LogEntry.EntryState.NEW, LogEntry.EntryState.NEW, 
LogEntry.EntryState.PROCESSED),
+                readStates(logFile));
+    }
+
+    @Test
+    void entryWhoseReplayFailsIsReplayedOnTheNextStart() throws Exception {
+        File logFile = writeNewEntries(2);
+
+        Delegate delegate = new Delegate("key-0");
+        stop(restart(logFile, delegate));
+
+        assertEquals(List.of("key-0=0", "key-1=1"), delegate.replayed);
+        assertEquals(List.of("key-0=FAILED", "key-1=PROCESSED"), 
readLog(logFile));
+
+        Delegate next = new Delegate();
+        stop(restart(logFile, next));
+        assertEquals(List.of("key-0=0"), next.replayed);
+    }
+
+    @Test
+    void unconfirmedEntriesStayInTheLogUntilTheyAreReplayed() throws Exception 
{
+        File logFile = writeNewEntries(2);
+
+        // stopped (or crashed) after start() but before loadCache() replayed 
the entries
+        Delegate notReplayed = new Delegate();
+        stop(start(logFile, notReplayed));
+        assertEquals(List.of(), notReplayed.replayed);
+        assertEquals(List.of("key-0=NEW", "key-1=NEW"), readLog(logFile));
+
+        Delegate delegate = new Delegate();
+        stop(restart(logFile, delegate));
+        assertEquals(List.of("key-0=0", "key-1=1"), delegate.replayed);
+    }
+
+    @Test
+    void callbackOfThePreviousStartDoesNotUpdateAnEntryOfTheNewLog() throws 
Exception {
+        File logFile = new File(testDir, "wal.data");
+        LateConfirmingDelegate delegate = new LateConfirmingDelegate("key-1");
+        WriteAheadResumeStrategy strategy = restart(logFile, delegate);
+        strategy.updateLastOffset(OffsetKeys.of("key-0"), Offsets.of(0L));
+        strategy.updateLastOffset(OffsetKeys.of("key-1"), Offsets.of(1L));
+        UpdateCallBack late = delegate.callbacks.get(0);
+
+        // the route is stopped and started again (the same strategy) while 
the callback of key-1 is pending: key-1 is
+        // carried into the new log, and a new offset of key-2 is confirmed 
there, where key-1 was in the old log
+        strategy.stop();
+        strategy.start();
+        strategy.loadCache();
+        strategy.updateLastOffset(OffsetKeys.of("key-2"), Offsets.of(2L));
+
+        // the callback of the previous start reports late
+        late.onUpdate(new IllegalStateException("the delegate failed late"));
+        stop(strategy);
+
+        assertEquals(List.of("key-1=NEW", "key-2=PROCESSED"), 
readLog(logFile));
+    }
+
+    @Test
+    void logThatMayHaveRolledOverIsNotReplayed() throws Exception {
+        // the log keeps 3 entries: the fourth offset rolled it over, so the 
file holds 4, then the older 2 and 3
+        File logFile = writeLog(3,
+                entry(LogEntry.EntryState.NEW, "key-0", 1),
+                entry(LogEntry.EntryState.NEW, "key-0", 2),
+                entry(LogEntry.EntryState.NEW, "key-0", 3),
+                entry(LogEntry.EntryState.NEW, "key-0", 4));
+
+        Delegate delegate = new NeverConfirmingDelegate();
+        WriteAheadResumeStrategy strategy = start(logFile, delegate, 3);
+        strategy.loadCache();
+        stop(strategy);
+
+        // the last entry of the key in the file is the older offset 3: 
replaying it would resume before 4
+        assertEquals(List.of(), delegate.replayed);
+        assertEquals(List.of(), readLog(logFile), "a new log replaces the 
previous one");
+    }
+
+    @Test
+    void logThatHasNotRolledOverIsReplayed() throws Exception {
+        File logFile = writeLog(3,
+                entry(LogEntry.EntryState.NEW, "key-0", 1),
+                entry(LogEntry.EntryState.NEW, "key-0", 2));
+
+        Delegate delegate = new NeverConfirmingDelegate();
+        WriteAheadResumeStrategy strategy = start(logFile, delegate, 3);
+        strategy.loadCache();
+        stop(strategy);
+
+        assertEquals(List.of("key-0=2"), delegate.replayed);
+        assertEquals(List.of("key-0=NEW"), readLog(logFile));
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = { 3, 6, 16, 27, 40 })
+    void truncatedLastRecordIsSkippedAndThePreviousOnesAreReplayed(int 
bytesLeftOfTheLastRecord) throws Exception {
+        File logFile = writeNewEntries(3);
+        truncate(logFile, Header.BYTES + 2 * RECORD_SIZE + 
bytesLeftOfTheLastRecord);
+
+        Delegate delegate = new Delegate();
+        stop(restart(logFile, delegate));
+
+        assertEquals(List.of("key-0=0", "key-1=1"), delegate.replayed);
+        assertEquals(List.of("key-0=PROCESSED", "key-1=PROCESSED"), 
readLog(logFile), "the new log is complete");
+    }
+
+    @Test
+    void truncatedLastRecordAcrossTheReadBufferIsSkipped() throws Exception {
+        // the last record starts in the first read of the reader's buffer, 
and the file ends 2 bytes after it
+        File logFile = new File(testDir, "wal.data");
+        int count = 0;
+        try (LogWriter writer = new LogWriter(logFile, new 
DefaultLogSupervisor(100_000))) {
+            long end;
+            do {
+                LogEntry entry = entry(LogEntry.EntryState.NEW, "key-" + 
count, count);
+                end = writer.append(entry).getPosition() + entry.size();
+                count++;
+            } while (end <= LogReader.DEFAULT_CAPACITY + 2);
+        }
+        truncate(logFile, LogReader.DEFAULT_CAPACITY + 2);
+
+        Delegate delegate = new Delegate();
+        stop(restart(logFile, delegate));
+
+        assertEquals(count - 1, delegate.replayed.size());
+        assertEquals("key-" + (count - 2) + "=" + (count - 2), 
delegate.replayed.get(count - 2));
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+            // the position in the last record, the value written there
+            "8, -5", // key length
+            "25, -1", // value length
+            "8, 2147483647", // key length, larger than the reader's buffer
+            "8, 1000", // key length, beyond the end of the file
+            "0, 99" // state
+    })
+    void invalidLastRecordDoesNotFailTheStart(int positionInRecord, int value) 
throws Exception {
+        File logFile = writeNewEntries(3);
+        writeIntAt(logFile, Header.BYTES + 2 * RECORD_SIZE + positionInRecord, 
value);
+
+        Delegate delegate = new Delegate();
+        stop(restart(logFile, delegate));
+
+        assertEquals(List.of("key-0=0", "key-1=1"), delegate.replayed);
+        assertEquals(List.of("key-0=PROCESSED", "key-1=PROCESSED"), 
readLog(logFile));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "no file", "empty", "header only", "shorter than 
a header", "other format", "other version" })
+    void startsWithNothingToReplay(String content) throws Exception {
+        File logFile = new File(testDir, "wal.data");
+        switch (content) {
+            case "no file":
+                break;
+            case "empty":
+                assertTrue(logFile.createNewFile());
+                break;
+            case "header only":
+                writeLog();
+                break;
+            case "shorter than a header":
+                Files.write(logFile.toPath(), new byte[] { 'c', 'a', 'm', 'e', 
'l' });
+                break;
+            case "other format":
+                writeNewEntries(1);
+                Files.write(logFile.toPath(), "other-fm".getBytes(), 
StandardOpenOption.WRITE);
+                break;
+            default:
+                writeNewEntries(1);
+                writeIntAt(logFile, Header.FORMAT_NAME_SIZE, 
Header.CURRENT_FILE_VERSION + 1);
+                break;
+        }
+
+        Delegate delegate = new Delegate();
+        WriteAheadResumeStrategy strategy = restart(logFile, delegate);
+        strategy.updateLastOffset(OffsetKeys.of("key-9"), Offsets.of(9L));
+        stop(strategy);
+
+        assertEquals(List.of("key-9=9"), delegate.replayed, "only the offset 
of this run is updated");
+        assertEquals(List.of("key-9=PROCESSED"), readLog(logFile), "a new log 
replaces the previous one");
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 0d6dd068dcbb..70a0b2850701 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -4075,3 +4075,24 @@ were delivered on the wrong topic. The producer now 
publishes a new `MqttPropert
 message expiry interval, content type, response topic, correlation data and 
user properties of the header object, which
 a publish no longer modifies. A topic alias or subscription identifiers set in 
the header object are not sent: the
 client assigns topic aliases for its connection, and subscription identifiers 
are sent by the server only.
+
+=== camel-wal - the unconfirmed offsets of the previous run are replayed after 
a restart
+
+`WriteAheadResumeStrategy` now does what it is documented to do after a 
restart: when the consumer starts and loads the
+resume cache (`loadCache()`), the strategy replays the offsets of the previous 
run that the wrapped resume strategy did
+not confirm, by calling `updateLastOffset` on the wrapped strategy. Before, 
`start()` truncated the log file before
+`loadCache()` read it, so nothing was replayed. Only the last offset of each 
key in the log counts: it is replayed when
+its entry is in state `NEW` or `FAILED`, and an earlier offset of the same key 
is not replayed. These entries stay in
+the log until the wrapped strategy confirms them through its callback; a 
wrapped strategy that never calls back gets
+the last offset of each such key again at every start. The Kafka consumer does 
not call `loadCache()`, so with it the
+entries are kept in the log but not replayed. A replay that fails, or an entry 
that the resume adapter cannot
+deserialize, is logged with a WARN and does not stop the start of the route. 
If the last record of the log is
+incomplete (the JVM stopped while writing it) or invalid, the entries before 
it are replayed and a WARN is logged; the
+route still starts. A file that is not a log of this format is replaced by a 
new log, as before, with a WARN.
+
+The log keeps 524,288 entries; after that it rolls over and holds the newest 
entries before the older ones, and the
+file does not say which are newer. So a log that holds that many entries is 
not replayed, as before: a WARN names the
+number of unconfirmed entries, which are discarded with the log. Replaying the 
wrong offset of a key would make the
+consumer resume from an older offset. A log that rolled over and was cut short 
because the system stopped while the
+log rewrote itself after the roll-over holds fewer entries and is replayed: 
the last entry of a key in it can then be
+an older offset, so the consumer can process again the messages after that 
offset.

Reply via email to