feat: Implement idle source handling for WatermarkStatus#67
Conversation
lgbo-ustc
left a comment
There was a problem hiding this comment.
Thanks for the PR! The design is clean and well-thought-out. Below are my detailed review comments:
WatermarkIdleTracker
-
checkIdlewithlastRecordWallTime_ == 0baseline logic: When no record has ever arrived,checkIdlesetslastRecordWallTime_ = currentWallMsand returns false. This correctly prevents an immediate IDLE declaration on a cold source. However, ifcheckIdleis called repeatedly before any record arrives, each call will keep resettinglastRecordWallTime_to the current time, effectively postponing idle detection indefinitely. Is this the intended behavior? In Flink,WatermarkAssignerOperatorstarts the idle timer from the firstopen()call, not from the firstcheckIdle(). Consider using a separateinitialized_flag to only set the baseline once. -
setIdleis public but only used in tests: ThesetIdlemethod is exposed in the public API but only used by tests. Consider making itprotectedand usingfriend classorFRIEND_TESTto limit its visibility. This avoids accidental misuse in production code. -
idle_state transition asymmetry:onRecordtransitions idle→active on the first record after idle, which is correct. ButcheckIdleonly transitions active→idle after the timeout elapses. Consider whether areset()method is needed for checkpoint/restore scenarios where the tracker state needs to be fully reset.
WatermarkAssigner / WatermarkSource
-
scheduleIdleTimerrace:timerPending_.exchange(true)is used to prevent duplicate timer registrations. However, there is a subtle race:- Thread A calls
scheduleIdleTimer,exchange(true)returns false, timer is registered. - Timer fires on the scheduler thread, sets
timerPending_ = false. - Before
checkWatermarkStatusruns on the driver thread,scheduleIdleTimeris called again,exchange(true)returns false, another timer is registered. - The first timer's wake-up triggers a drain, then the second timer fires, triggering another drain.
This is likely harmless (an extra wake-up), but the comment in
WatermarkAssigner.hsaystimerPending_"signals checkWatermarkStatus to perform an idle check." In reality,checkWatermarkStatusalways performs the idle check regardless oftimerPending_. The flag only guards against duplicate registrations. The comment should be clarified. - Thread A calls
-
Duplicate code between WatermarkAssigner and WatermarkSource: The idle timer management logic (
scheduleIdleTimer,onIdleTimerFired,scheduler_lifetime,timerPending_) is nearly identical betweenWatermarkAssignerandWatermarkSource. Consider extracting a shared helper class (e.g.,IdleTimerManager) to avoid the duplication. This would also reduce the risk of fixing a bug in one place but not the other. -
ProcessingTimeSchedulerheader missing from includes:WatermarkAssigner.cppandWatermarkSource.cppuseSystemProcessingTimeSchedulerandProcessingTimerTaskbut do not include the corresponding header directly. They rely on the header being transitively included viaWatermarkAssigner.h/WatermarkSource.h. It is better practice to explicitly include the header in the .cpp files to make dependencies explicit. -
close()in destructor:~WatermarkAssigner()callsWatermarkAssigner::close(), and~WatermarkSource()callsWatermarkSource::close(). Calling virtual methods (or methods that themselves call virtual methods likeclose()) from constructors/destructors is generally discouraged in C++. Whileclose()is not marked virtual in the diff,StatefulOperator::close()is called from it, and it could have virtual dispatch implications. Consider moving the scheduler cleanup to the destructor body directly or ensuringclose()is non-virtual.
StatefulTask
-
checkWatermarkStatuscalled on every drain attempt: Innext(),checkWatermarkStatusis now called in three paths: when source is blocked, when output is empty, and when source is empty. The third path (sourceEmpty()) already returns nullptr without producing output, so callingcheckWatermarkStatusthere is correct. But note that whensourceEmpty()returns true and the scheduler is not running (no timer wake-up),checkWatermarkStatusis the only path for idle detection — which is the design intent and looks correct. -
Potential busy-loop when
pendings_becomes non-empty due to idle status: AftercheckWatermarkStatusemits aWatermarkStatusintopendings_, the flow falls through topopOutput(). This is correct. But if the idle timer fires frequently (e.g., a very shortidleTimeout), the drain loop innext()could produce manyWatermarkStatusevents rapidly. Consider whether there should be a rate-limit or deduplication mechanism to avoid flooding the output with redundant status events.
Tests
-
Good coverage: 9 tests cover the key scenarios: disabled, normal flow, idempotent idle, reactivation, early fire prevention, baseline, exact boundary, and explicit
setIdle. Nicely done. -
Missing test for
checkIdlebaseline reset behavior: The testfirstCheckSetsBaselineWithoutFiringonly tests a singlecheckIdlecall before a record. Consider adding a test that verifies the behavior whencheckIdleis called multiple times before any record arrives (see the concern about the baseline logic above).
Minor / Nits
WatermarkGenerator.hremoves a TODO comment about idle source handling — great to see it implemented!WatermarkAssigner.hconstructor init list has a whitespace-only change on thetargets_line (extra space). Consider reverting to keep the diff clean.- The PR description references issue
apache/gluten#12340— consider addingClosesorRefsin the commit message for cross-repo linkage.
Overall, this is a solid implementation. The main architectural suggestion is to extract the shared idle timer logic to reduce duplication. The race condition is minor but the comment should be fixed. The checkIdle baseline reset behavior should be clarified or fixed.
73173f3 to
4270ea3
Compare
Add WatermarkIdleTracker for pure-logic idle detection and integrate it into WatermarkAssigner and WatermarkSource to detect input idleness. Use native SystemProcessingTimeScheduler timer + NativeCallbackBridge->onProcessingTime to wake the Java mailbox, with idle state transitions performed on the driver thread via checkWatermarkStatus() hook in StatefulTask::next(). - Add WatermarkIdleTracker.h/.cpp: pure logic idle detection class - Add WatermarkIdleTrackerTest.cpp: 9 unit tests - Modify WatermarkAssigner: integrate idleTracker + native timer + emit ACTIVE/IDLE - Modify WatermarkGenerator: expose idleTracker for WatermarkSource - Modify WatermarkSource: integrate idleTracker + native timer for source-level detection - Modify StatefulOperator: add virtual ~StatefulOperator() and checkWatermarkStatus() - Modify StatefulTask: call checkWatermarkStatus in next() after each drain attempt - Update CMakeLists for new source files
Remove scheduleIdleTimer from data arrival paths (addInput/advance) to eliminate per-record overhead. Use atomic timerPending_ flag to ensure at most one timer is pending at any time, re-registering only after the previous timer fires and is consumed by checkWatermarkStatus. - addInput/advance: only call onRecord(), no timer registration - scheduleIdleTimer: guard with timerPending_.exchange(true) - onIdleTimerFired: clear timerPending_ before signalling - checkWatermarkStatus: move scheduleIdleTimer outside the if block
timerPending_ already signals timer-fire events via its true→false transition. Drop idleCheckRequested_ and call checkIdle directly in checkWatermarkStatus. checkIdle is guarded by idle_ so it cannot double-emit WatermarkStatus.IDLE.
Add baselineInitialized_ flag so the first checkIdle() call without any prior onRecord() sets the baseline only once. Previously the lastRecordWallTime_ == 0 check would reset on every call, indefinitely postponing idle detection. Also fix timerPending_ comment to accurately describe its sole purpose as duplicate-registration guard (not as a signal for checkWatermarkStatus). Add repeatedCheckIdleDoesNotResetBaseline test case.
4270ea3 to
154a48e
Compare
KevinyhZou
left a comment
There was a problem hiding this comment.
Overall direction looks good: idle transitions on the driver thread + timer only waking the mailbox is the right split. The baseline reset issue is already fixed. Requesting changes on three blockers before merge (details in inline comments).
Blockers
- Blocked path in
StatefulTask::next()drops pendingWatermarkStatusemitted bycheckWatermarkStatus. - Idle timer is scheduled as
now + idleTimeoutinstead oflastRecordWallTime + idleTimeout, so IDLE can be delayed by up to ~2× timeout. - After already IDLE, timers keep being re-registered → periodic no-op mailbox wakeups.
Also please
- Deduplicate idle-timer logic between
WatermarkAssigner/WatermarkSource - Avoid calling
close()from destructor (extract scheduler shutdown) - Add integration tests (not only
WatermarkIdleTrackerunit tests) - Fix stale comments (
idleCheckRequested_, “lazily created on first record”)
Nice parts: baselineInitialized_, driver-thread state transition, idempotent checkIdle, and timerPending_ duplicate-registration guard.
There was a problem hiding this comment.
[Blocker 1] Blocked path drops already-emitted WatermarkStatus
if (future != nullptr && future->valid()) {
checkWatermarkStatus(...); // may push IDLE into pendings_
return nullptr; // discards that pending output
}The empty-output path below correctly re-checks pendings_ and falls through to popOutput(). The blocked path does not.
Typical failure: timer wakes mailbox → next() → source still blocked → IDLE is computed this round but returned as nullptr.
Suggestion:
if (future != nullptr && future->valid()) {
checkWatermarkStatus(TimeWindowUtil::getCurrentProcessingTime());
if (!pendings_.empty()) {
return popOutput();
}
return nullptr;
}Please treat this as a merge blocker and add a regression test that covers blocked + idle emission in the same next() call.
There was a problem hiding this comment.
[Blocker 2] Timer scheduled from now, not from last record time
int64_t fireAt = now + idleTimeout_;checkIdle measures idleness from lastRecordWallTime_, but the wake-up timer is anchored at the current check time. Because addInput deliberately does not re-register the timer, a late check can push fire time close to lastRecord + 2 * idleTimeout, delaying IDLE by up to ~one full timeout.
Suggestion: expose last-record (or remaining) from WatermarkIdleTracker and schedule:
fireAt = lastRecordWallTime + idleTimeout; // clamp to now if already due[Blocker 3] Keep scheduling idle timers after already IDLE
scheduleIdleTimer(now) runs unconditionally even when idleTracker_ is already idle (checkIdle returns false and is a no-op). After IDLE is emitted we still wake the Java mailbox every idleTimeout for no useful status change → empty next() churn.
Suggestion: skip scheduleIdleTimer when already idle (re-arm only after ACTIVE / onRecord).
[Suggestion] Calling close() from the destructor
~WatermarkAssigner() {
WatermarkAssigner::close();
}StatefulTask::finish() usually already called close(). Prefer a dedicated shutdownScheduler() used by both close() and the destructor, and keep business close() out of ~T(). Same pattern in ~WatermarkSource().
There was a problem hiding this comment.
[Nit] Stale comments
scheduleIdleTimer comment still refers to idleCheckRequested_, which was removed in commit 68e45f50. Please update to match the current design (timerPending_ is only a duplicate-registration guard; idle check always runs in checkWatermarkStatus).
Related: the scheduler_ comment (“Lazily created on the first record arrival”) is also inaccurate — creation happens on the first scheduleIdleTimer from checkWatermarkStatus.
There was a problem hiding this comment.
[Blocker 2 / duplication] Same now + idleTimeout scheduling bug here
WatermarkSource copies the WatermarkAssigner timer pattern (fireAt = now + idleTimeout). Please fix both sites, and strongly consider extracting shared idle-timer management (IdleTimerManager / helper) so Assigner and Source cannot drift again.
Also note: after already IDLE, scheduleIdleTimer is still always called from checkWatermarkStatus (same as Blocker 3).
Minor: empty batches early-return before onRecord — confirm whether that matches the intended Flink semantics (any element vs non-empty data).
There was a problem hiding this comment.
[Suggestion] Missing integration coverage
Unit tests for WatermarkIdleTracker look solid (including the repeated-baseline case). Please also add higher-level tests covering:
- Blocked source + idle → same
StatefulTask::next()returnsWatermarkStatus(Blocker 1) - Timer fire time ≈
lastRecord + idleTimeout(Blocker 2) - Already-idle does not keep re-arming / does not re-emit IDLE (Blocker 3)
- ACTIVE reactivation then second IDLE transition
Without these, the critical races in StatefulTask / Assigner / Source stay unguarded.
Summary
Implements idle source handling for Gluten Flink (issue apache/gluten#12340), using native
SystemProcessingTimeSchedulertimer +NativeCallbackBridge->onProcessingTimeto detect idle input and emitWatermarkStatus.IDLE/ACTIVE.Changes
New files
WatermarkIdleTracker.h/.cpp— Pure-logic idle detection class (onRecord,checkIdle,isIdle,isEnabled)tests/WatermarkIdleTrackerTest.cpp— 9 unit testsModified files
WatermarkAssigner.h/.cpp— Integrate idleTracker +SystemProcessingTimeSchedulertimer +callbackBridge->onProcessingTime; emit ACTIVE on data arrival, IDLE viacheckWatermarkStatus()hookWatermarkGenerator.h/.cpp— AddWatermarkIdleTrackermember, exposeonRecord/checkIdle/idleTimeoutfor source-level useWatermarkSource.h/.cpp— Same timer+idle pattern as WatermarkAssigner for source-level watermark pushdownStatefulOperator.h/.cpp— Addvirtual ~StatefulOperator() = defaultandvirtual void checkWatermarkStatus(int64_t now)with recursive forwarding to targetsStatefulTask.h/.cpp— CallcheckWatermarkStatus()innext()after each drain attempt (both blocked and empty-output paths)CMakeLists.txt— Register new source filesDesign
std::atomic<bool>flag + calls bridge to wake Java mailbox — never writes topendings_directlyemitWatermarkStatushappens on driver thread incheckWatermarkStatus(), avoiding racescheckWatermarkStatusis called on every drain attempt, so idle detection works even without callback bridge (e.g. WatermarkSource path)Verification
WatermarkIdleTrackerunit tests passlibvelox.socompiles successfully in Docker containerlgbo_flink_native_compile