Copilot commented on code in PR #4740:
URL: https://github.com/apache/bookkeeper/pull/4740#discussion_r3048747327


##########
bookkeeper-server/src/main/java/org/apache/bookkeeper/bookie/Journal.java:
##########
@@ -776,6 +788,15 @@ public void checkpointComplete(Checkpoint checkpoint, 
boolean compact) throws IO
         LogMarkCheckpoint lmcheckpoint = (LogMarkCheckpoint) checkpoint;
         LastLogMark mark = lmcheckpoint.mark;
 
+        // Monotonic check: only advance lastMark forward, never backwards.
+        synchronized (checkpointLock) {
+            if (mark.getCurMark().compare(lastPersistedMark) <= 0) {
+                return;
+            }
+            lastPersistedMark.setLogMark(
+                    mark.getCurMark().getLogFileId(), 
mark.getCurMark().getLogFileOffset());
+        }
+
         mark.rollLog(mark);

Review Comment:
   lastPersistedMark is advanced before rollLog() and before any I/O/GC occurs. 
If rollLog() throws (e.g., NoWritableLedgerDirException) or fails to persist 
the file, subsequent checkpointComplete calls with the same/newer mark can be 
skipped, leaving lastMark on disk stale. Update lastPersistedMark only after 
successfully persisting lastMark (or keep it consistent by performing the 
monotonic decision + persistence atomically under the lock).
   ```suggestion
           // Keep the monotonic decision and persistence consistent by only
           // updating lastPersistedMark after rollLog succeeds.
           synchronized (checkpointLock) {
               if (mark.getCurMark().compare(lastPersistedMark) <= 0) {
                   return;
               }
               mark.rollLog(mark);
               lastPersistedMark.setLogMark(
                       mark.getCurMark().getLogFileId(), 
mark.getCurMark().getLogFileOffset());
           }
   ```



##########
bookkeeper-server/src/test/java/org/apache/bookkeeper/bookie/storage/ldb/DbLedgerStorageTest.java:
##########
@@ -881,4 +882,121 @@ public void 
testSingleLedgerDirectoryCheckpointTriggerRemovePendingDeletedLedger
         bookie.getLedgerStorage().flush();
         Assert.assertEquals(pendingDeletedLedgers.size(), 0);
     }
+
+    /**
+     * Simulates the full journal-missing scenario caused by concurrent 
checkpointComplete
+     * calls in single-dir mode and verifies the monotonic fix prevents it.
+     *
+     * <p>Timeline of the original bug:
+     * <pre>
+     * 1. SDLS.flush() starts: newCheckpoint → mark(5, 0), begins flushing data
+     * 2. SyncThread starts:   newCheckpoint → mark(7, 0), waits for flushMutex
+     * 3. SDLS.flush() completes flushing, releases flushMutex
+     * 4. SyncThread acquires flushMutex, finds writeCache empty, returns
+     * 5. SyncThread calls checkpointComplete(mark=7, compact=true) FIRST
+     *    → rollLog(7), GC deletes journal files 3,4,5,6 (all with id < 7)
+     * 6. SDLS.flush() calls checkpointComplete(mark=5, compact=true) SECOND
+     *    → rollLog(5) — OVERWRITES lastMark backwards from 7 to 5!
+     *    → journal file 5 was already deleted in step 5
+     * 7. Bookie restarts: reads lastMark=5, looks for journal file 5 → 
MISSING!
+     *    → throws "Recovery log 5 is missing"
+     * </pre>
+     *
+     * <p>With the monotonic fix, step 6 is skipped (mark 5 <= 
lastPersistedMark 7),
+     * so lastMark stays at 7 and the restart succeeds.
+     */
+    @Test
+    public void testConcurrentCheckpointCompleteJournalMissing() throws 
Exception {
+        File baseDir = new File(tmpDir, "journalMissingTest");
+        File ledgerDir = new File(baseDir, "ledger");
+        File journalBaseDir = new File(baseDir, "journal");
+        ServerConfiguration conf = 
TestBKConfiguration.newServerConfiguration();
+        conf.setGcWaitTime(1000);
+        conf.setProperty(DbLedgerStorage.WRITE_CACHE_MAX_SIZE_MB, 4);
+        conf.setProperty(DbLedgerStorage.READ_AHEAD_CACHE_MAX_SIZE_MB, 4);
+        conf.setLedgerStorageClass(DbLedgerStorage.class.getName());
+        conf.setLedgerDirNames(new String[] { ledgerDir.getCanonicalPath() });
+        conf.setJournalDirName(journalBaseDir.getCanonicalPath());
+        // Set maxBackupJournals to 0 so GC aggressively deletes all old 
journals
+        conf.setMaxBackupJournals(0);
+
+        BookieImpl bookie = new TestBookieImpl(conf);
+        try {
+            Journal journal = bookie.getJournals().get(0);
+            File journalDir = journal.getJournalDirectory();
+            File ledgerDirMark = new File(ledgerDir + "/current", "lastMark");
+
+            // Create fake journal files: 3.txn, 4.txn, 5.txn, 6.txn, 7.txn, 
8.txn
+            for (long id = 3; id <= 8; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertTrue("Failed to create journal file " + id, 
journalFile.createNewFile());
+            }
+
+            // Verify all journal files exist
+            for (long id = 3; id <= 8; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertTrue("Journal file " + id + " should exist", 
journalFile.exists());
+            }
+
+            CheckpointSource checkpointSource = new 
CheckpointSourceList(bookie.getJournals());
+
+            // === Simulate the race ===
+
+            // Step 1: SDLS.flush() captures checkpoint at mark(5, 100)
+            journal.getLastLogMark().getCurMark().setLogMark(5, 100);
+            CheckpointSource.Checkpoint cpFlush = 
checkpointSource.newCheckpoint();
+
+            // Step 2: SyncThread captures checkpoint at mark(7, 200) — newer 
position
+            journal.getLastLogMark().getCurMark().setLogMark(7, 200);
+            CheckpointSource.Checkpoint cpSync = 
checkpointSource.newCheckpoint();
+
+            // Step 5: SyncThread completes FIRST — checkpointComplete(mark=7, 
compact=true)
+            // This should: rollLog to 7, GC journals with id < 7 (deletes 
3,4,5,6)
+            checkpointSource.checkpointComplete(cpSync, true);
+
+            LogMark markAfterSync = readLogMark(ledgerDirMark);
+            assertEquals("lastMark should be at 7 after SyncThread", 7, 
markAfterSync.getLogFileId());
+            assertEquals(200, markAfterSync.getLogFileOffset());
+
+            // Verify journals 3,4,5,6 were GC'd, 7,8 still exist
+            for (long id = 3; id <= 6; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertFalse("Journal " + id + " should have been GC'd", 
journalFile.exists());
+            }
+            for (long id = 7; id <= 8; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertTrue("Journal " + id + " should still exist", 
journalFile.exists());
+            }
+
+            // Step 6: SDLS.flush() completes SECOND — 
checkpointComplete(mark=5, compact=true)
+            // WITHOUT FIX: rollLog would overwrite lastMark to 5, but journal 
5 is already deleted!
+            // WITH FIX: mark 5 <= lastPersistedMark 7, so this is skipped 
entirely.
+            checkpointSource.checkpointComplete(cpFlush, true);
+

Review Comment:
   This test doesn’t actually exercise the concurrency ordering that caused the 
bug: both checkpointComplete calls run sequentially in the same thread. With 
the current implementation, a stale checkpoint that *entered* 
checkpointComplete before a newer one (but completes after) can still regress 
lastMark; adding a thread/latch-based test that forces that interleaving would 
better validate the fix.



##########
bookkeeper-server/src/test/java/org/apache/bookkeeper/bookie/storage/ldb/DbLedgerStorageTest.java:
##########
@@ -881,4 +882,121 @@ public void 
testSingleLedgerDirectoryCheckpointTriggerRemovePendingDeletedLedger
         bookie.getLedgerStorage().flush();
         Assert.assertEquals(pendingDeletedLedgers.size(), 0);
     }
+
+    /**
+     * Simulates the full journal-missing scenario caused by concurrent 
checkpointComplete
+     * calls in single-dir mode and verifies the monotonic fix prevents it.
+     *
+     * <p>Timeline of the original bug:
+     * <pre>
+     * 1. SDLS.flush() starts: newCheckpoint → mark(5, 0), begins flushing data
+     * 2. SyncThread starts:   newCheckpoint → mark(7, 0), waits for flushMutex
+     * 3. SDLS.flush() completes flushing, releases flushMutex
+     * 4. SyncThread acquires flushMutex, finds writeCache empty, returns
+     * 5. SyncThread calls checkpointComplete(mark=7, compact=true) FIRST
+     *    → rollLog(7), GC deletes journal files 3,4,5,6 (all with id < 7)
+     * 6. SDLS.flush() calls checkpointComplete(mark=5, compact=true) SECOND
+     *    → rollLog(5) — OVERWRITES lastMark backwards from 7 to 5!
+     *    → journal file 5 was already deleted in step 5
+     * 7. Bookie restarts: reads lastMark=5, looks for journal file 5 → 
MISSING!
+     *    → throws "Recovery log 5 is missing"
+     * </pre>
+     *
+     * <p>With the monotonic fix, step 6 is skipped (mark 5 <= 
lastPersistedMark 7),
+     * so lastMark stays at 7 and the restart succeeds.
+     */
+    @Test
+    public void testConcurrentCheckpointCompleteJournalMissing() throws 
Exception {
+        File baseDir = new File(tmpDir, "journalMissingTest");
+        File ledgerDir = new File(baseDir, "ledger");
+        File journalBaseDir = new File(baseDir, "journal");
+        ServerConfiguration conf = 
TestBKConfiguration.newServerConfiguration();
+        conf.setGcWaitTime(1000);
+        conf.setProperty(DbLedgerStorage.WRITE_CACHE_MAX_SIZE_MB, 4);
+        conf.setProperty(DbLedgerStorage.READ_AHEAD_CACHE_MAX_SIZE_MB, 4);
+        conf.setLedgerStorageClass(DbLedgerStorage.class.getName());
+        conf.setLedgerDirNames(new String[] { ledgerDir.getCanonicalPath() });
+        conf.setJournalDirName(journalBaseDir.getCanonicalPath());
+        // Set maxBackupJournals to 0 so GC aggressively deletes all old 
journals
+        conf.setMaxBackupJournals(0);
+
+        BookieImpl bookie = new TestBookieImpl(conf);
+        try {
+            Journal journal = bookie.getJournals().get(0);
+            File journalDir = journal.getJournalDirectory();
+            File ledgerDirMark = new File(ledgerDir + "/current", "lastMark");
+
+            // Create fake journal files: 3.txn, 4.txn, 5.txn, 6.txn, 7.txn, 
8.txn
+            for (long id = 3; id <= 8; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertTrue("Failed to create journal file " + id, 
journalFile.createNewFile());
+            }
+
+            // Verify all journal files exist
+            for (long id = 3; id <= 8; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertTrue("Journal file " + id + " should exist", 
journalFile.exists());
+            }
+
+            CheckpointSource checkpointSource = new 
CheckpointSourceList(bookie.getJournals());
+
+            // === Simulate the race ===
+
+            // Step 1: SDLS.flush() captures checkpoint at mark(5, 100)
+            journal.getLastLogMark().getCurMark().setLogMark(5, 100);
+            CheckpointSource.Checkpoint cpFlush = 
checkpointSource.newCheckpoint();
+
+            // Step 2: SyncThread captures checkpoint at mark(7, 200) — newer 
position
+            journal.getLastLogMark().getCurMark().setLogMark(7, 200);
+            CheckpointSource.Checkpoint cpSync = 
checkpointSource.newCheckpoint();
+
+            // Step 5: SyncThread completes FIRST — checkpointComplete(mark=7, 
compact=true)
+            // This should: rollLog to 7, GC journals with id < 7 (deletes 
3,4,5,6)
+            checkpointSource.checkpointComplete(cpSync, true);
+
+            LogMark markAfterSync = readLogMark(ledgerDirMark);
+            assertEquals("lastMark should be at 7 after SyncThread", 7, 
markAfterSync.getLogFileId());
+            assertEquals(200, markAfterSync.getLogFileOffset());
+
+            // Verify journals 3,4,5,6 were GC'd, 7,8 still exist
+            for (long id = 3; id <= 6; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertFalse("Journal " + id + " should have been GC'd", 
journalFile.exists());
+            }
+            for (long id = 7; id <= 8; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertTrue("Journal " + id + " should still exist", 
journalFile.exists());
+            }
+
+            // Step 6: SDLS.flush() completes SECOND — 
checkpointComplete(mark=5, compact=true)
+            // WITHOUT FIX: rollLog would overwrite lastMark to 5, but journal 
5 is already deleted!
+            // WITH FIX: mark 5 <= lastPersistedMark 7, so this is skipped 
entirely.
+            checkpointSource.checkpointComplete(cpFlush, true);
+
+            // Verify: lastMark must NOT regress to 5. Should stay at 7.
+            LogMark markAfterFlush = readLogMark(ledgerDirMark);
+            assertEquals("lastMark must not regress after older checkpoint 
completes",
+                    7, markAfterFlush.getLogFileId());
+            assertEquals(200, markAfterFlush.getLogFileOffset());
+
+            // Step 7: Simulate bookie restart — read lastMark and check 
journal exists
+            journal.getLastLogMark().readLog();
+            LogMark restartMark = journal.getLastLogMark().getCurMark();
+            assertEquals("Reloaded lastMark should be 7", 7, 
restartMark.getLogFileId());

Review Comment:
   The “Simulate bookie restart” section calls lastLogMark.readLog() on the 
existing Journal instance whose in-memory curMark has already been set to 7 
earlier in the test. Since readLog() only updates curMark if the on-disk mark 
is greater, this doesn’t validate that lastMark is actually being reloaded. To 
better simulate a restart, reset curMark before readLog() or create a new 
Bookie/Journal instance pointing at the same dirs and assert it loads the 
persisted mark.
   ```suggestion
               // Step 7: Simulate bookie restart — clear in-memory state, then 
reload lastMark from disk
               journal.getLastLogMark().getCurMark().setLogMark(0, 0);
               journal.getLastLogMark().readLog();
               LogMark restartMark = journal.getLastLogMark().getCurMark();
               assertEquals("Reloaded lastMark should be 7", 7, 
restartMark.getLogFileId());
               assertEquals("Reloaded lastMark offset should be restored from 
disk",
                       200, restartMark.getLogFileOffset());
   ```



##########
bookkeeper-server/src/test/java/org/apache/bookkeeper/bookie/storage/ldb/DbLedgerStorageTest.java:
##########
@@ -881,4 +882,121 @@ public void 
testSingleLedgerDirectoryCheckpointTriggerRemovePendingDeletedLedger
         bookie.getLedgerStorage().flush();
         Assert.assertEquals(pendingDeletedLedgers.size(), 0);
     }
+
+    /**
+     * Simulates the full journal-missing scenario caused by concurrent 
checkpointComplete
+     * calls in single-dir mode and verifies the monotonic fix prevents it.
+     *
+     * <p>Timeline of the original bug:
+     * <pre>
+     * 1. SDLS.flush() starts: newCheckpoint → mark(5, 0), begins flushing data
+     * 2. SyncThread starts:   newCheckpoint → mark(7, 0), waits for flushMutex
+     * 3. SDLS.flush() completes flushing, releases flushMutex
+     * 4. SyncThread acquires flushMutex, finds writeCache empty, returns
+     * 5. SyncThread calls checkpointComplete(mark=7, compact=true) FIRST
+     *    → rollLog(7), GC deletes journal files 3,4,5,6 (all with id < 7)
+     * 6. SDLS.flush() calls checkpointComplete(mark=5, compact=true) SECOND
+     *    → rollLog(5) — OVERWRITES lastMark backwards from 7 to 5!
+     *    → journal file 5 was already deleted in step 5
+     * 7. Bookie restarts: reads lastMark=5, looks for journal file 5 → 
MISSING!
+     *    → throws "Recovery log 5 is missing"
+     * </pre>
+     *
+     * <p>With the monotonic fix, step 6 is skipped (mark 5 <= 
lastPersistedMark 7),
+     * so lastMark stays at 7 and the restart succeeds.
+     */
+    @Test
+    public void testConcurrentCheckpointCompleteJournalMissing() throws 
Exception {
+        File baseDir = new File(tmpDir, "journalMissingTest");
+        File ledgerDir = new File(baseDir, "ledger");
+        File journalBaseDir = new File(baseDir, "journal");
+        ServerConfiguration conf = 
TestBKConfiguration.newServerConfiguration();
+        conf.setGcWaitTime(1000);
+        conf.setProperty(DbLedgerStorage.WRITE_CACHE_MAX_SIZE_MB, 4);
+        conf.setProperty(DbLedgerStorage.READ_AHEAD_CACHE_MAX_SIZE_MB, 4);
+        conf.setLedgerStorageClass(DbLedgerStorage.class.getName());
+        conf.setLedgerDirNames(new String[] { ledgerDir.getCanonicalPath() });
+        conf.setJournalDirName(journalBaseDir.getCanonicalPath());
+        // Set maxBackupJournals to 0 so GC aggressively deletes all old 
journals
+        conf.setMaxBackupJournals(0);
+
+        BookieImpl bookie = new TestBookieImpl(conf);
+        try {
+            Journal journal = bookie.getJournals().get(0);
+            File journalDir = journal.getJournalDirectory();
+            File ledgerDirMark = new File(ledgerDir + "/current", "lastMark");
+
+            // Create fake journal files: 3.txn, 4.txn, 5.txn, 6.txn, 7.txn, 
8.txn
+            for (long id = 3; id <= 8; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertTrue("Failed to create journal file " + id, 
journalFile.createNewFile());
+            }
+
+            // Verify all journal files exist
+            for (long id = 3; id <= 8; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertTrue("Journal file " + id + " should exist", 
journalFile.exists());
+            }
+
+            CheckpointSource checkpointSource = new 
CheckpointSourceList(bookie.getJournals());
+
+            // === Simulate the race ===
+
+            // Step 1: SDLS.flush() captures checkpoint at mark(5, 100)
+            journal.getLastLogMark().getCurMark().setLogMark(5, 100);
+            CheckpointSource.Checkpoint cpFlush = 
checkpointSource.newCheckpoint();
+
+            // Step 2: SyncThread captures checkpoint at mark(7, 200) — newer 
position
+            journal.getLastLogMark().getCurMark().setLogMark(7, 200);
+            CheckpointSource.Checkpoint cpSync = 
checkpointSource.newCheckpoint();
+
+            // Step 5: SyncThread completes FIRST — checkpointComplete(mark=7, 
compact=true)
+            // This should: rollLog to 7, GC journals with id < 7 (deletes 
3,4,5,6)
+            checkpointSource.checkpointComplete(cpSync, true);
+
+            LogMark markAfterSync = readLogMark(ledgerDirMark);
+            assertEquals("lastMark should be at 7 after SyncThread", 7, 
markAfterSync.getLogFileId());
+            assertEquals(200, markAfterSync.getLogFileOffset());
+
+            // Verify journals 3,4,5,6 were GC'd, 7,8 still exist
+            for (long id = 3; id <= 6; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertFalse("Journal " + id + " should have been GC'd", 
journalFile.exists());
+            }
+            for (long id = 7; id <= 8; id++) {
+                File journalFile = new File(journalDir, Long.toHexString(id) + 
".txn");
+                assertTrue("Journal " + id + " should still exist", 
journalFile.exists());
+            }
+
+            // Step 6: SDLS.flush() completes SECOND — 
checkpointComplete(mark=5, compact=true)
+            // WITHOUT FIX: rollLog would overwrite lastMark to 5, but journal 
5 is already deleted!
+            // WITH FIX: mark 5 <= lastPersistedMark 7, so this is skipped 
entirely.
+            checkpointSource.checkpointComplete(cpFlush, true);
+
+            // Verify: lastMark must NOT regress to 5. Should stay at 7.
+            LogMark markAfterFlush = readLogMark(ledgerDirMark);
+            assertEquals("lastMark must not regress after older checkpoint 
completes",
+                    7, markAfterFlush.getLogFileId());
+            assertEquals(200, markAfterFlush.getLogFileOffset());
+
+            // Step 7: Simulate bookie restart — read lastMark and check 
journal exists
+            journal.getLastLogMark().readLog();
+            LogMark restartMark = journal.getLastLogMark().getCurMark();
+            assertEquals("Reloaded lastMark should be 7", 7, 
restartMark.getLogFileId());
+
+            // The journal file pointed to by lastMark must exist
+            File markedJournal = new File(journalDir,
+                    Long.toHexString(restartMark.getLogFileId()) + ".txn");
+            assertTrue("Journal file " + restartMark.getLogFileId() + " must 
exist for recovery",
+                    markedJournal.exists());
+
+            // Verify that listJournalIds finds the expected journal for replay
+            List<Long> replayLogs = Journal.listJournalIds(journalDir,
+                    journalId -> journalId >= restartMark.getLogFileId());
+            assertTrue("Replay journal list must contain the marked journal",
+                    replayLogs.size() > 0 && replayLogs.get(0) == 
restartMark.getLogFileId());
+        } finally {
+            bookie.getLedgerStorage().shutdown();

Review Comment:
   Cleanup only shuts down the LedgerStorage. BookieImpl also starts SyncThread 
and Journal threads; leaving them running can cause thread leaks and test 
flakiness. Prefer calling bookie.shutdown() (or otherwise stopping 
journals/sync thread) in the finally block.
   ```suggestion
               bookie.shutdown();
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to