RockteMQ-AI commented on issue #10668:
URL: https://github.com/apache/rocketmq/issues/10668#issuecomment-5110639375
**Fix Spec Generated (v1)**
Code base: `00e45b8a6db2` (develop)
# Fix Specification — Issue #10668 (apache/rocketmq)
**Title:** Timer state (TimerLog / TimerWheel / timer cursors) is not
corrected after CommitLog truncation, leaving stale CommitLog references that
stall/mislead Timer enqueue & dequeue.
---
## 1. Root Cause
### 1.1 Truncation never reaches the timer subsystem
`DefaultMessageStore.truncateDirtyFiles(long offsetToTruncate)`
(`store/src/main/java/org/apache/rocketmq/store/DefaultMessageStore.java:785-818`)
performs:
1. `reputMessageService.shutdown()` (line 794)
2. `truncateDirtyLogicFiles(offsetToTruncate)` →
`consumeQueueStore.truncateDirty(phyOffset)` (lines 799, 821-823)
3. `commitLog.truncateDirtyFiles(offsetToTruncate)` (line 802)
4. `recoverTopicQueueTable()` (line 804), then restarts a fresh reput
service (lines 806-817)
The `timerMessageStore` field (DefaultMessageStore.java:169, setter at
:1118) is **never referenced** in this flow. There is no hook, listener, or
callback interface for truncation events. Runtime callers of truncation are the
HA auto-switch path:
- `AutoSwitchHAService.truncateInvalidMsg()` →
`defaultMessageStore.truncateDirtyFiles(reputFromOffset)`
(`AutoSwitchHAService.java:545`)
- `AutoSwitchHAClient` → `messageStore.truncateFiles(truncateOffset)`
(`AutoSwitchHAClient.java:462` → `DefaultMessageStore.truncateFiles`, :826-838)
### 1.2 Why stale state breaks the timer
`TimerMessageStore`
(`store/src/main/java/org/apache/rocketmq/store/timer/TimerMessageStore.java`)
keeps three pieces of state that embed CommitLog physical offsets:
- **TimerLog** entries (`TimerLog.java:33-41`, UNIT_SIZE=48) persist
`offsetPy` (CommitLog phy offset) and `sizePy` per delayed message.
Append-only; no correction API.
- **TimerWheel** slots (`Slot.java:19-50`) store `firstPos`/`lastPos`
pointing into TimerLog; those TimerLog entries in turn hold the (possibly
stale) CommitLog offsets.
- **Cursors**: `currQueueOffset` (TIMER topic consume-queue offset) and
`currReadTimeMs`/`currWriteTimeMs`.
<details>
<summary>Full Specification</summary>
# Fix Specification — Issue #10668 (apache/rocketmq)
**Title:** Timer state (TimerLog / TimerWheel / timer cursors) is not
corrected after CommitLog truncation, leaving stale CommitLog references that
stall/mislead Timer enqueue & dequeue.
---
## 1. Root Cause
### 1.1 Truncation never reaches the timer subsystem
`DefaultMessageStore.truncateDirtyFiles(long offsetToTruncate)`
(`store/src/main/java/org/apache/rocketmq/store/DefaultMessageStore.java:785-818`)
performs:
1. `reputMessageService.shutdown()` (line 794)
2. `truncateDirtyLogicFiles(offsetToTruncate)` →
`consumeQueueStore.truncateDirty(phyOffset)` (lines 799, 821-823)
3. `commitLog.truncateDirtyFiles(offsetToTruncate)` (line 802)
4. `recoverTopicQueueTable()` (line 804), then restarts a fresh reput
service (lines 806-817)
The `timerMessageStore` field (DefaultMessageStore.java:169, setter at
:1118) is **never referenced** in this flow. There is no hook, listener, or
callback interface for truncation events. Runtime callers of truncation are the
HA auto-switch path:
- `AutoSwitchHAService.truncateInvalidMsg()` →
`defaultMessageStore.truncateDirtyFiles(reputFromOffset)`
(`AutoSwitchHAService.java:545`)
- `AutoSwitchHAClient` → `messageStore.truncateFiles(truncateOffset)`
(`AutoSwitchHAClient.java:462` → `DefaultMessageStore.truncateFiles`, :826-838)
### 1.2 Why stale state breaks the timer
`TimerMessageStore`
(`store/src/main/java/org/apache/rocketmq/store/timer/TimerMessageStore.java`)
keeps three pieces of state that embed CommitLog physical offsets:
- **TimerLog** entries (`TimerLog.java:33-41`, UNIT_SIZE=48) persist
`offsetPy` (CommitLog phy offset) and `sizePy` per delayed message.
Append-only; no correction API.
- **TimerWheel** slots (`Slot.java:19-50`) store `firstPos`/`lastPos`
pointing into TimerLog; those TimerLog entries in turn hold the (possibly
stale) CommitLog offsets.
- **Cursors**: `currQueueOffset` (TIMER topic consume-queue offset) and
`currReadTimeMs`/`currWriteTimeMs`.
After `truncateDirtyFiles(offset)`:
- CommitLog data at `[offset, oldMax)` is gone; the TIMER topic consume
queue is shortened by `consumeQueueStore.truncateDirty`.
- TimerLog/TimerWheel are untouched — they continue to reference CommitLog
offsets ≥ `offset`.
- `currQueueOffset` may now exceed the TIMER consume queue max offset.
`enqueue()` (TimerMessageStore.java:751-836) only clamps against **min** offset
(lines 762-766); there is no max-offset clamp outside of startup `recover()`
(lines 332-336). Enqueue then repeatedly calls `cq.iterateFrom(offset)` for a
nonexistent offset and returns `false` forever — enqueue is effectively stuck
and newly written timer messages after the truncation point are never picked up
until restart.
- On dequeue, `dequeue()` (lines 1015-1124) walks stale TimerLog chains and
submits `TimerRequest`s with invalid `offsetPy`.
`TimerDequeueGetMessageService` (lines 1694-1775) calls
`getMessageByCommitOffset(tr.getOffsetPy(), tr.getSizePy())` (lines 1159-1169),
which retries the read **3 times per request** before returning null (each
attempt logs `"Fail to read msg from commitLog"`). With densely populated slots
this produces sustained retry churn/log flood; the request is then dropped via
`tr.idempotentRelease()` (lines 1747-1751). Additionally, `dequeue()` blocks in
`checkDequeueLatch` (lines 985-1012, called at 1098/1108) while the get-service
grinds through the retries, so the read cursor advances slowly ("dequeue stuck
retrying invalid messages").
- Startup recovery cannot repair this either: `recover()` (lines 299-363)
only runs from `load()`, and `timerWheel.checkPhyPos()`
(`TimerWheel.java:326-344`) validates **TimerLog** positions, not the CommitLog
offsets embedded in TimerLog entries. `TimerCheckpoint`
(`TimerCheckpoint.java:39-43`) does not record any CommitLog watermark, so a
truncation that happened while running is invisible to the next recovery.
**Summary:** the truncation event is not propagated to `TimerMessageStore`,
and the timer read paths have no defensive bounds check against the CommitLog's
current `[minOffset, maxOffset)` window.
## 2. Fix Strategy
Two complementary layers: an event-driven correction (primary) and defensive
bounds checks (safety net).
### 2.1 Primary — notify TimerMessageStore from `truncateDirtyFiles`
Add a correction method to `TimerMessageStore`, e.g. `public void
onCommitLogDispatchTruncate(long offsetToTruncate)` and invoke it from
`DefaultMessageStore.truncateDirtyFiles()` after `recoverTopicQueueTable()`
(i.e. after CommitLog/CQ are consistent, ~line 804), guarded by `if
(this.timerMessageStore != null)`.
The method must, under the enqueue/dequeue locks already used by the timer
services:
1. **Pause** enqueue/dequeue processing (reuse the existing running-state
flags, e.g. temporarily clear `isRunningEnqueue`/`isRunningDequeue` semantics
or hold their locks).
2. **Drain in-flight queues** (`enqueuePutQueue`, `dequeueGetQueue`,
`dequeuePutQueue`) of `TimerRequest`s whose `offsetPy >= offsetToTruncate`,
releasing their latches via `idempotentRelease()` so `dequeue()` is not left
waiting on `checkDequeueLatch`.
3. **Clamp `currQueueOffset`** to `min(currQueueOffset,
cq.getMaxOffsetInQueue())` for the TIMER topic consume queue (mirror of the
startup logic at TimerMessageStore.java:332-336); update `commitQueueOffset`
accordingly and call `prepareTimerCheckPoint()` (lines 1942-1954) so the
checkpoint is durable.
4. TimerLog entries with stale `offsetPy` remain physically on disk
(append-only format); they are neutralized by layer 2.2 below rather than
rewritten.
### 2.2 Defensive — fast-fail on out-of-range CommitLog offsets
In `getMessageByCommitOffset` (TimerMessageStore.java:1159-1169): before the
3-attempt retry loop, check
`offsetPy < messageStore.getCommitLog().getMinOffset() || offsetPy + sizePy
> messageStore.getMaxPhyOffset()` → log once at WARN and return `null`
immediately. This removes the 3× retry amplification for permanently-gone
offsets in both `enqueue()` (line 784), `reviseQueueOffset()` (line 373), and
the dequeue get-service (line 1718), while preserving retries for transient
read failures on valid offsets.
In `enqueue()` (lines 762-766): add the symmetric max-offset clamp:
```java
if (currQueueOffset > cq.getMaxOffsetInQueue()) {
LOGGER.warn(...);
currQueueOffset = cq.getMaxOffsetInQueue();
}
```
so enqueue self-heals even if the notification path is bypassed (e.g. plugin
stores).
Dequeue-side behavior for null messages (drop + `idempotentRelease`, lines
1747-1751) is kept — with the fast-fail check it now completes promptly instead
of spinning through retries.
## 3. Files to Modify
| File | Change |
|---|---|
| `store/src/main/java/org/apache/rocketmq/store/DefaultMessageStore.java` |
In `truncateDirtyFiles()` (785-818), after `recoverTopicQueueTable()`, invoke
`timerMessageStore.onCommitLogDispatchTruncate(offsetToTruncate)` when
non-null. |
|
`store/src/main/java/org/apache/rocketmq/store/timer/TimerMessageStore.java` |
New `onCommitLogDispatchTruncate(long)`; bounds fast-fail in
`getMessageByCommitOffset()` (1159-1169); max-offset clamp in `enqueue()`
(after 766). |
|
`store/src/test/java/org/apache/rocketmq/store/timer/TimerMessageStoreTest.java`
| New tests (see §4). |
No changes needed to `TimerLog.java`, `TimerWheel.java`, `Slot.java`, or
`TimerCheckpoint.java` (on-disk formats untouched).
## 4. Test Plan
Extend `TimerMessageStoreTest` (pattern of `testStateAndRecover()`, lines
468-540):
1. **testDequeueAfterCommitLogTruncate** — put N timer messages, wait until
enqueued into TimerWheel; call `messageStore.truncateDirtyFiles(offset)`
cutting the last K messages; assert: (a) dequeue does not block —
`currReadTimeMs` keeps advancing past the affected slots within a bounded time;
(b) the surviving N−K messages are delivered; (c) no request remains stuck in
`dequeueGetQueue`/`dequeuePutQueue`.
2. **testEnqueueCursorClampAfterTruncate** — drive `currQueueOffset` past
the truncated CQ max, call truncation, then put a new timer message; assert
enqueue resumes and the new message is delivered (verifies the max clamp).
3. **testGetMessageByCommitOffsetFastFail** — unit-level: offset beyond
`getMaxPhyOffset()` returns null without 3 store reads (verify via
counter/mock).
4. **testRecoverAfterTruncateAndRestart** — truncate, shutdown, restart
`TimerMessageStore`; assert `recover()` (299-363) completes and
`currQueueOffset ≤ cq.getMaxOffsetInQueue()`.
5. **Regression** — run existing `TimerMessageStoreTest`, `TimerLogTest`,
`TimerWheelTest`, `TimerCheckPointTest`, and HA `AutoSwitchHAService`-related
tests.
## 5. Backward Compatibility
- **On-disk formats unchanged**: TimerLog unit layout (48B), TimerWheel slot
layout, and TimerCheckpoint layout are untouched — rolling upgrade/downgrade
safe.
- **Public API**: one new public method on `TimerMessageStore`; no interface
(`MessageStore`) signature changes, so plugin stores
(`AbstractPluginMessageStore`) are unaffected.
- **Behavioral change**: messages whose CommitLog data was truncated are now
skipped promptly instead of retried 3×; they were already unrecoverable (data
deleted), so no delivery semantics regress. Truncation on HA switch already
implies the truncated tail is invalid on this replica; the new master retains
authoritative copies.
- `timerMessageStore == null` (timer disabled, `timerWheelEnable=false`) —
guard keeps old behavior.
## 6. Risk Assessment
| Risk | Level | Mitigation |
|---|---|---|
| Deadlock between truncation thread and timer service threads while
pausing/draining | Medium | Use existing lock ordering of enqueue/dequeue
services; drain queues with `poll()` not blocking `take()`; add timeout + WARN
log. Covered by test 1. |
| Dropping an in-flight `TimerRequest` without releasing its latch →
`dequeue()` blocks in `checkDequeueLatch` (985-1012) | Medium | Always
`idempotentRelease()` drained requests (idempotent by design). |
| Fast-fail bounds check races with concurrent CommitLog append
(maxPhyOffset moving forward) | Low | Only *skips retry* when offset is beyond
max at check time; a message freshly appended is ≤ max by the time its
TimerRequest is created, and the check re-reads live offsets. Worst case falls
through to the existing 3-retry loop. |
| `currQueueOffset` clamp interacting with slave sync
(`masterTimerQueueOffset`, TimerCheckpoint.java:39-43) | Low | Clamp uses same
rule already applied in `recover()` (332-336); checkpoint refreshed via
`prepareTimerCheckPoint()`. |
| Missed correction on code paths not going through
`DefaultMessageStore.truncateDirtyFiles` | Low | Defensive layer (§2.2) makes
enqueue/dequeue self-healing regardless of notification. |
**Overall: Medium-low.** Changes are localized to the store module timer
path; the defensive layer alone eliminates the stuck/retry symptom, and the
notification layer restores cursor consistency without touching persisted
formats.
</details>
**Next Steps:**
- Reply `/approve` to proceed with PR generation
- Reply `/revise <feedback>` to request changes
- Reply `/reject` to close this proposal
*This proposal expires in 72 hours.*
---
*Automated by github-manager-bot*
--
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]