Skip to content

[ISSUE #11156]fix(timer): persist TimelineRollService checkpoint to avoid repeated and loss - #11157

Open
3424672656 wants to merge 5 commits into
apache:developfrom
3424672656:fix_timer_rocksdb_roll
Open

[ISSUE #11156]fix(timer): persist TimelineRollService checkpoint to avoid repeated and loss#11157
3424672656 wants to merge 5 commits into
apache:developfrom
3424672656:fix_timer_rocksdb_roll

Conversation

@3424672656

@3424672656 3424672656 commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Which Issue(s) This PR Fixes

Brief Description

What is the problem?

TimelineRollService used a fixed sleep plus a scan window of [now + rollRange, now + rollRange + timerMaxDelaySec].

Two issues follow from that:

  1. The window is much larger than the interval, so the same not-yet-expired timer message stays in range and is rolled back to TIMER_TOPIC many times.
  2. The next window is computed from System.currentTimeMillis() after sleep. If a scan is delayed, the next round can skip or rescan the same delayTime range. The progress was also only in memory, so a restart could roll the same messages again.

What is the solution?

Drive roll by a persisted RocksDB checkpoint (timeline_roll_checkpoint) instead of a fixed sleep:

  • Each round scans [checkpoint, checkpoint + interval).
  • After a successful scan, advance and persist the checkpoint.
  • The next due time is checkpoint + interval - timerMaxDelaySec. Trigger 1s early; if it is not due yet, poll with at most 1s wait.
  • If the service is behind, scan the next window immediately so delayed work does not leave a gap.

Test plan

  • MessageRocksDBStorageTest#testWriteAndGetRollCheckpoint
  • MessageRocksDBStorageTest#testScanAdjacentWindowsNoOverlap
  • Send a long-delay timer message (e.g. 3h, timerMaxDelaySec a bit larger) and confirm it is rolled at most once
  • Slow down or restart broker during roll and confirm the next scan continues from the checkpoint without skipping or duplicating the previous window

…rolls

Scan hour-sized windows from a RocksDB checkpoint instead of sleeping a fixed interval, so delayed scans do not skip or re-roll the same timer messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codecov-commenter

codecov-commenter commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 6.45161% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.29%. Comparing base (1a50c6e) to head (b3b7490).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
.../apache/rocketmq/store/timer/rocksdb/Timeline.java 0.00% 23 Missing ⚠️
.../store/timer/rocksdb/TimerMessageRocksDBStore.java 0.00% 6 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop   #11157      +/-   ##
=============================================
- Coverage      49.39%   49.29%   -0.11%     
+ Complexity     14237    14210      -27     
=============================================
  Files           1390     1390              
  Lines         103123   103121       -2     
  Branches       13484    13486       +2     
=============================================
- Hits           50940    50832     -108     
- Misses         46031    46104      +73     
- Partials        6152     6185      +33     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@RockteMQ-AI

Copy link
Copy Markdown
Contributor

🔍 Code Review — PR #11157

bash: line 180: develop: command not found
bash: line 180: TimelineRollService: command not found
bash: line 180: [now: command not found
bash: line 180: TIMER_TOPIC: command not found
bash: command substitution: line 181: syntax error: unexpected end of file
bash: line 180: timeline_roll_checkpoint: command not found
bash: command substitution: line 180: syntax error near unexpected token )' bash: command substitution: line 180: [checkpoint, checkpoint + interval)'
bash: line 180: checkpoint: command not found
bash: line 180: MessageRocksDBStorageTest#testWriteAndGetRollCheckpoint: command not found
bash: line 180: MessageRocksDBStorageTest#testScanAdjacentWindowsNoOverlap: command not found
bash: line 180: timerMaxDelaySec: command not found
Overall
The change correctly moves the roll progress from memory to a RocksDB checkpoint, which is the right direction for avoiding duplicate scans across restarts. However, the checkpoint is advanced before the rolled records are actually written back to the commitlog by the async rollMessageReputService, so a crash can skip a window that was queued but not yet consumed. I think that needs to be fixed before merging.


1. Correctness

Persisting the checkpoint too early — likely data-loss/late-roll risk
store/src/main/java/org/apache/rocketmq/store/timer/rocksdb/Timeline.java:390-392

if (!scanRecordsToQueue(checkpoint, rangeMs, timerMessageRocksDBStore.getRollMessageQueue())) {
    ...
}
checkpoint += rangeMs;
messageRocksDBStorage.writeCheckPointForTimer(...);

scanRecordsToQueue only offers the records to rollMessageQueue. The actual re-put into the commitlog happens asynchronously in TimerMessageReputService. If the broker crashes after the checkpoint is persisted but before the queue consumer finishes, the next start will skip that window. The records are still in RocksDB and will eventually be delivered by TimelineForwardService, but the early-roll guarantee is broken and the PR’s own test claim (“continue from the checkpoint without skipping … the previous window”) is violated.

Consider advancing the checkpoint only after the roll reput service has finished processing the window, similar to how TimerMessageReputService.writeCheckPoint works for the expired queue.

Initial checkpoint uses rollRangeHour while the scan window is rollIntervalHour
store/src/main/java/org/apache/rocketmq/store/timer/rocksdb/Timeline.java:383-384

if (checkpoint <= 0L) {
    checkpoint = System.currentTimeMillis() + maxDelayMs - TimeUnit.HOURS.toMillis(rollRangeHour);
}

The window width is rangeMs = rollIntervalHour, but the initial offset is rollRangeHour. If an operator configures rollRangeHour < rollIntervalHour, the first upper bound becomes now + maxDelay + (rollIntervalHour - rollRangeHour), i.e. messages that are still farther than maxDelay away can be rolled too early. At best this wastes work; at worst it can roll a message that the timer wheel cannot yet hold.

Please validate that rollRangeHour >= rollIntervalHour, or initialize with rangeMs instead.

No backoff on unexpected exception
store/src/main/java/org/apache/rocketmq/store/timer/rocksdb/Timeline.java:395-397

The outer catch (Exception e) logs and loops immediately. If RocksDB throws a persistent error the thread will spin. Add a short waitForRunning in the error path.


2. Performance

Frequent checkpoint sync writes when catching up
store/src/main/java/org/apache/rocketmq/store/timer/rocksdb/Timeline.java:391

writeCheckPointForTimer writes through the WAL. When the service is behind it will scan consecutive 1-hour windows and persist after every single one. For a large backlog this is a lot of small sync writes. Consider persisting only every N windows or batching the advance.

Hot loop on storeConfig.isTimerStopDequeue()
store/src/main/java/org/apache/rocketmq/store/timer/rocksdb/Timeline.java:388

scanRecordsToQueue returns false immediately when dequeue is stopped, then the new code waits only 200 ms. That is fine, but the log level is error for an intentional pause — should be warn or info.


3. Tests

No unit tests for TimelineRollService scheduling logic
The PR only adds:

  • testWriteAndGetRollCheckpoint — validates the checkpoint key read/write.
  • testScanAdjacentWindowsNoOverlap — validates [lower, upper) scan semantics.

Missing coverage:

  • service waits when the next window is not yet due,
  • service scans consecutive windows when behind,
  • checkpoint is persisted and resumed on restart,
  • no duplicate scan of the same window.

Please add a test for TimelineRollService.run() using a mocked MessageRocksDBStorage and a fake clock, or extract the scheduling into a package-private method.

New test is flaky because it reuses global delay-time windows
store/src/test/java/org/apache/rocketmq/store/rocksdb/MessageRocksDBStorageTest.java:155-178

testScanAdjacentWindowsNoOverlap uses begin = (System.currentTimeMillis() / window) * window. Other tests in the same class write records near currentTime + 3600000L, which can fall into these windows and break the exact-size assertions depending on execution order/time. Use isolated, far-future delay times or clean the column family between tests.

Pasted diff artifact
The diff block shows lines like:

@auth/src/test/java/org/apache/rocketmq/auth/authentication/AuthenticationEvaluatorTest.java
public void testWriteAndGetRollCheckpoint() {

Assuming this is a paste/rendering artifact, please confirm the real file uses @Test.


4. Compatibility

Behavioral change of timerRocksDBRollIntervalHours / timerRocksDBRollRangeHours
Previously rollIntervalHours controlled sleep and rollRangeHours controlled the scan start offset, while the scan width was always timerMaxDelaySec. Now rollIntervalHours is also the scan window width, and rollRangeHours only affects the first checkpoint. Existing deployments that tuned these configs will see different roll frequency and load patterns. Please update the config documentation and consider a release note.

Downgrade path
store/src/main/java/org/apache/rocketmq/store/rocksdb/MessageRocksDBStorage.java:75

The new TIMELINE_ROLL_CHECK_POINT key is harmless to older code (it ignores unknown keys), but an older broker restarting after a new broker has advanced the checkpoint will not read it and will rescan already-rolled windows, potentially producing duplicate timer messages. That may be acceptable, but it should be documented.


Recommendation: Request changes. The checkpoint-before-consumption issue and the test flakiness should be addressed; the config compatibility note should be documented.


Automated review by github-manager bot. Please verify suggestions before applying.

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR persists the TimelineRollService checkpoint to RocksDB to avoid duplicate scans and message loss across restarts. The approach is sound in principle, but the checkpoint is advanced before the async roll reput service finishes processing, which can cause window skipping on crash. Several other concerns around initial checkpoint alignment, error handling, and test isolation need attention.

Findings

  • [Critical] Timeline.java:390 — Checkpoint advanced before async roll completes; crash can skip windows
  • [Warning] Timeline.java:383 — Initial checkpoint uses rollRangeHour but window is rollIntervalHour; mismatch risk
  • [Warning] Timeline.java:395 — No backoff on persistent RocksDB errors; thread will spin
  • [Info] Timeline.java:391 — Consider batching checkpoint writes to reduce WAL overhead
  • [Warning] MessageRocksDBStorageTest.java:155 — Test window boundaries may collide with other tests

Automated review by github-manager-bot

Comment thread store/src/main/java/org/apache/rocketmq/store/timer/rocksdb/Timeline.java Outdated
if (storeConfig.getTimerRocksDBRollRangeHours() > 0) {
rollRangeHour = storeConfig.getTimerRocksDBRollRangeHours();
long maxDelayMs = TimeUnit.SECONDS.toMillis(storeConfig.getTimerMaxDelaySec());
int rollIntervalHour = storeConfig.getTimerRocksDBRollIntervalHours() > 0 ? storeConfig.getTimerRocksDBRollIntervalHours() : 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Initial checkpoint uses rollRangeHour while the scan window is rollIntervalHour (rangeMs). If rollRangeHour < rollIntervalHour, the first upper bound extends beyond maxDelay, potentially rolling messages too early.

Please validate that rollRangeHour >= rollIntervalHour, or initialize with rangeMs instead.

@3424672656
3424672656 force-pushed the fix_timer_rocksdb_roll branch from 52d7457 to 5cdcf91 Compare September 13, 2026 03:38
TimelineRollService used to write the checkpoint right after a scan, so a crash
between the scan and the reput dropped the whole window. The reput service now
owns the write and takes the key to write, which lets the roll queue persist its
own checkpoint the same way the expired queue already does. Roll keeps its scan
frontier in memory and advances it per window, as TimelineForwardService does.

Without a persisted roll checkpoint the frontier is derived from the delivery
checkpoint plus one maximum delay, so a broker that ran without this key
re-scans its windows instead of skipping them.

Co-authored-by: Cursor <cursoragent@cursor.com>
A checkpoint driven loop advances one window per window width, so the width and
the cadence are the same quantity and only one config can define it. Keep
timerRocksDBRollRangeHours as the scan window width and remove
timerRocksDBRollIntervalHours, which no longer has a role of its own.

The outer catch used to log and retry immediately, spinning the thread while
rocksdb keeps throwing.

Co-authored-by: Cursor <cursoragent@cursor.com>

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR persists the TimelineRollService checkpoint to RocksDB to avoid duplicate scans and message loss across restarts. The updated version addresses all previously raised concerns:

  1. Checkpoint persistence timing ✅ — Checkpoint is now persisted by TimerRollReputService.doReput() after records are successfully written to the commitlog, ensuring crash-safety.
  2. Initial checkpoint alignment ✅ — Initialization properly calculates the starting checkpoint from maxDelay, avoiding out-of-range windows on first run.
  3. Error handling backoff ✅ — Thread.sleep(200) added in both the scan-error and general-exception paths, preventing tight spin loops on persistent errors.
  4. Test isolation ✅ — Tests use a fixed time base (BASE_TIME_MS) instead of System.currentTimeMillis(), ensuring deterministic behavior.

The removal of timerRocksDBRollIntervalHours simplifies the configuration model by deriving the roll cadence from rollRangeHours and the delay window. Note: this is a minor backward-incompatible config change — users who had customized this value will need to remove it from their configs.

Overall the approach is sound and the implementation is now correct. LGTM.


Automated review by github-manager-bot

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-reviewed after new commits. The checkpoint-driven roll approach is well-implemented — persisted RocksDB checkpoint replaces the fragile fixed-sleep logic, preventing both duplicate and lost timer message rolls across restarts.

Key changes look correct:

  • TIMELINE_ROLL_CHECK_POINT properly registered in COMMON_CHECK_POINT_KEY_SET_FOR_TIMER
  • Poll-based trigger with 1s early wake is a reasonable trade-off between precision and CPU
  • Removal of timerRocksDBRollIntervalHours is consistent since the interval is now derived from the checkpoint

LGTM — re-approved with the latest changes.


Automated review by github-manager-bot

Co-authored-by: Cursor <cursoragent@cursor.com>

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR fixes a critical correctness issue in TimelineRollService where the fixed-interval sleep + wall-clock-based scan window could cause timer messages to be rolled multiple times or skipped after restarts. The checkpoint-driven approach with persisted RocksDB state is the right solution.

Findings

  • [Critical] TimerMessageRocksDBStore.java:548 — Yoda condition style inconsistent with codebase
  • [Warning] Timeline.java:395 — Tight error loop risk on persistent failures
  • [Info] Timeline.java:385 — Checkpoint initialization could log recovery scenarios
  • [Info] Timeline.java:52 — Hardcoded timing constants work but could be configurable

Strengths

✅ Checkpoint persistence eliminates duplicate/skipped rolls across restarts
✅ Adjacent window scanning logic is correct and well-tested
✅ Refactoring TimerMessageReputService to use checkpoint keys is clean and extensible
✅ Test coverage for checkpoint independence and window boundaries is solid

Suggestions

  1. Add exponential backoff for error retries to avoid tight loops
  2. Log a warning when initial checkpoint is far behind current time (recovery scenario)
  3. Align null-check style with codebase conventions

Overall, this is a well-designed fix for a subtle concurrency/persistence bug. The changes are focused and the test coverage is good.

int rollRangeHour = storeConfig.getTimerRocksDBRollRangeHours() > 0 ? storeConfig.getTimerRocksDBRollRangeHours() : 2;
checkpoint = (forwardCheckpoint > 0L ? Math.min(forwardCheckpoint, now) : now)
+ TimeUnit.SECONDS.toMillis(storeConfig.getTimerMaxDelaySec()) - TimeUnit.HOURS.toMillis(rollRangeHour);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] The checkpoint initialization logic is solid — starting from min(forwardCheckpoint, now) + maxDelay - rollRange ensures we don't miss messages that were already queued for expiration.

One consideration: if forwardCheckpoint is significantly behind now (e.g., after a long outage), the initial checkpoint could be far in the past. The service will catch up by scanning multiple windows in sequence, which is correct, but might want to log a warning when checkpoint < now - rollRange to make recovery visible.

long now = System.currentTimeMillis();
if (now < triggerAt) {
this.waitForRunning(ROLL_POLL_WHEN_NOT_DUE_MS);
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] The error handling loop waits only 200ms on failure before retrying. If scanRecordsToQueue consistently fails (e.g., RocksDB corruption, disk full), this could create a tight error loop.

Consider adding exponential backoff or a max retry count before logging at ERROR level and pausing longer:

int consecutiveErrors = 0;
// ...
if (!scanRecordsToQueue(...)) {
    consecutiveErrors++;
    long backoff = Math.min(200L * (1L << Math.min(consecutiveErrors, 5)), 10000L);
    this.waitForRunning(backoff);
    if (consecutiveErrors > 10) {
        logError.error("TimelineRollService: {} consecutive failures, backing off {}ms", consecutiveErrors, backoff);
    }
    continue;
}
consecutiveErrors = 0;

private static final String DELETE_KEY_SPLIT = "+";
private static final int ORIGIN_CAPACITY = 100000;
private static final int BATCH_SIZE = 1000, MAX_BATCH_SIZE_FROM_ROCKSDB = 8000;
private static final long ROLL_TRIGGER_EARLY_MS = 1000L;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] The hardcoded constants ROLL_TRIGGER_EARLY_MS = 1000L and ROLL_POLL_WHEN_NOT_DUE_MS = 1000L work well for the current use case. If these need tuning in production, consider exposing them via MessageStoreConfig with sensible defaults.

countDownLatch.await();
log.info("TimerMessageReputService reput messages to commitlog, cost: {}, trs size: {}, checkPoint: {}", System.currentTimeMillis() - start, trs.size(), trs.get(trs.size() - 1).getCheckPoint());
if (this.writeCheckPoint && !CollectionUtils.isEmpty(trs) && trs.get(trs.size() - 1).getCheckPoint() > 0L) {
if (null != this.checkPointKey && !CollectionUtils.isEmpty(trs) && trs.get(trs.size() - 1).getCheckPoint() > 0L) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The null check null != this.checkPointKey is correct, but the Yoda condition style (null != x instead of x != null) is inconsistent with the rest of the codebase. RocketMQ style typically uses this.checkPointKey != null.

Minor, but worth aligning with project conventions for readability.

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR correctly addresses the issue of timer roll service repeating or losing rolls after restart by replacing the fixed-interval approach with checkpoint-based scheduling.

The implementation is clean:

  • Removes the unused timerRocksDBRollIntervalHours config
  • Adds TIMELINE_ROLL_CHECK_POINT for persisting roll state
  • Refactors TimelineRollService to use checkpoint-based scheduling
  • Updates TimerMessageReputService to support flexible checkpoint keys
  • Includes comprehensive tests

LGTM. Well-structured fix with proper test coverage.


Automated review by github-manager

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] TimelineRollService may repeatedly roll the same timer messages

3 participants