Context
While code-reviewing PR #536 (fix(precompute): keep wall-clock fallback from force-closing panes still under active ingest, part of the #474 fix stack —
asap-query-engine/src/precompute_engine/worker.rs), I found something that
still doesn't fully make sense to me and want a second pair of eyes on it,
since I'm not the original author of this engine. Writing out the full train
of thought below rather than jumping to the punchline, since the confusion
is as much about why the code is shaped this way as it is about behavior.
Commit reviewed: 380936401107d93934b6c91cbf6d3d7a9150337d on branch
474-precompute-wall-clock-e2e-test.
Step 1 — reviewing the regression test in PR #536
PR #536 adds wall_clock_fallback_does_not_close_a_pane_still_receiving_samples
(worker.rs, in the tests module). It touches a pane 8 times in a for i in 0..8 loop, then calls flush_all() and asserts the pane is not
force-closed, with a comment claiming the check happens "500ms after the
pane's last touch (i=6, at 1_006_000ms)".
That comment doesn't match the loop: 0..8 runs 8 iterations (i = 0..7), so
the actual last touch lands at i=7 → wall-clock 1_007_000ms, one whole
second later than the comment says. All 8 touches finish before
flush_all() is called even once, so the "mid-ingest" flush described in
the comment isn't really mid-ingest — it's post-ingest, with the fake clock
wound backward 500ms to construct the assertion. I confirmed this by
temporarily adding a debug print inside the wall-clock-fallback loop in
flush_all and re-running the test:
DEBUG flush_all: pane_start=0 pane_last_touch_ms=1007000 now_ms=1006500 delta=-500 threshold=6000
delta is negative — the assertion passes because the flush is (fictionally)
timestamped before the last touch, not because it's exercising a
"500ms-since-last-touch, still fresh" boundary. So the test passes, but not
for the reason its own comments describe.
Step 2 — fixing the test's loop bound surfaced a second, unrelated failure
To make the test actually call flush_all() mid-ingest (matching the
comment's intent — and matching what I understood production to do via its
periodic flush timer), I split the loop: 7 touches, then the "still fresh"
flush check, then an 8th touch, then the final closing flush.
That restructured test fails:
thread '...' panicked: expected all 8 touches merged: expected 36, got 28
28 = 1+2+...+7 — the 8th touch's value is missing from the final merged
output. The pane wasn't force-closed (sink.len() was 0 at the mid-ingest
check, as expected) — the 8th touch's sample was silently dropped by a
completely different mechanism than the one this PR touches.
Step 3 — tracing where the 8th touch went
Traced it to the "late data" guard in process_group_samples
(worker.rs:428):
if previous_wm != i64::MIN && *ts < previous_wm - allowed_lateness_ms {
// dropped as "late"
}
The 8th touch's timestamp is 0 (all samples in this test share one
event-time, deliberately, since that's the degenerate case #474 is about).
For it to be dropped as "late," state.previous_watermark_ms must have
already advanced past allowed_lateness_ms by the time it arrived — even
though the event-time watermark should be frozen at 0 the whole time,
since every sample carries the same timestamp.
Looking at flush_all (worker.rs:601-607):
let mut effective_wm = propagated_wm.saturating_add(1);
...
if effective_wm > state.previous_watermark_ms {
state.previous_watermark_ms = effective_wm;
}
propagated_wm is read from state.previous_watermark_ms itself, and the
result (+1) gets written straight back into that same field — every time
flush_all runs, regardless of whether any new data arrived or the
wall-clock fallback actually fired. So previous_watermark_ms creeps
forward by 1ms on every call to flush_all, even during a fully idle tick
where nothing happened.
Step 4 — this isn't a one-off; flush_all runs continuously in production
engine.rs:216-229 spawns a background timer that calls flush_all() on
every worker every flush_interval_ms (default 1000, config.rs:58), for
the entire lifetime of the pipeline — not just at shutdown. So the 1ms creep
described in Step 3 isn't confined to my test scenario; it happens on every
tick of that timer, the whole time a group has any open pane, whether or not
the group is receiving new data.
With the production default allowed_lateness_ms = 5000 (config.rs:56),
a stagnant-timestamp pane's own new samples would start getting dropped as
"late" once the creep pushes previous_watermark_ms past
ts + allowed_lateness_ms, i.e. roughly 5000 flush cycles ≈ 83 minutes
of continuous ingest with a frozen timestamp. My test's helper sets
allowed_lateness_ms: 0 (worker.rs:2870), which is why it only took one
flush cycle to manifest there.
This is the same symptom #474 originally described (silent row loss on a
bulk load whose rows share one event-time) but via a completely different
mechanism than the one PR #536 fixes, and it isn't mentioned anywhere in
that PR's design doc.
Step 5 — trying to understand why the +1 is there at all
The only comment on it is // Effective watermark: max(group's own, global) + 1ms for boundary. (worker.rs:601). My first assumption was that
closed_windows (window_manager.rs) requires the watermark to be
strictly greater than a window's end to close it, and that the +1 was
needed to cross that boundary. I checked, and that's wrong — the doc comment
on closed_windows (window_manager.rs:45-46) is explicit that it already
uses >=:
A window [start, start + window_size_ms) is closed when
current_wm >= start + window_size_ms.
What closed_windows does have is a separate early-return guard
(window_manager.rs:50): if current_wm <= previous_wm { return Vec::new(); } — i.e. it requires forward progress (current_wm strictly
greater than previous_wm) just to enter the closing logic at all, distinct
from the >= boundary check inside.
To test whether the +1 is load-bearing anywhere, I temporarily changed
effective_wm = propagated_wm.saturating_add(1) to effective_wm = propagated_wm (no nudge) and ran the full precompute_engine unit suite
(81 tests) plus the three related e2e tests
(e2e_precompute_wall_clock_fallback_active_ingest,
e2e_netflow_single_second, e2e_precompute_equivalence). Everything
passed, including the restructured mid-ingest test from Step 2. I reverted
this change immediately after — it was only a local experiment to answer
the question, not a proposed fix.
So, as far as I can tell from the test suite, the +1 isn't protecting any
currently-tested behavior — the two real cases that need forward progress on
an otherwise-idle tick (the wall-clock fallback's own force_to value, and
a slower worker catching up to a faster worker's globally-propagated
watermark) both already produce a value strictly greater than
state.previous_watermark_ms on their own, without help from the +1.
What I'm confused about / asking here
I don't have a confident answer for why the +1 exists, or whether there's
a scenario (maybe not covered by the current test suite, maybe involving
some interaction I'm not seeing between multi-worker coordination and window
alignment) where it actually matters. I also don't know whether the
83-minute-creep-into-late-data-drop behavior described in Steps 3-4 is a
known, accepted characteristic of this engine that just isn't written down
anywhere, or a genuine gap in the #474 fix. I'd like someone who knows this
engine's watermark/windowing design better than I do to weigh in on both
before anyone (myself included) tries to change anything here.
Context
While code-reviewing PR #536 (
fix(precompute): keep wall-clock fallback from force-closing panes still under active ingest, part of the #474 fix stack —asap-query-engine/src/precompute_engine/worker.rs), I found something thatstill doesn't fully make sense to me and want a second pair of eyes on it,
since I'm not the original author of this engine. Writing out the full train
of thought below rather than jumping to the punchline, since the confusion
is as much about why the code is shaped this way as it is about behavior.
Commit reviewed:
380936401107d93934b6c91cbf6d3d7a9150337don branch474-precompute-wall-clock-e2e-test.Step 1 — reviewing the regression test in PR #536
PR #536 adds
wall_clock_fallback_does_not_close_a_pane_still_receiving_samples(
worker.rs, in thetestsmodule). It touches a pane 8 times in afor i in 0..8loop, then callsflush_all()and asserts the pane is notforce-closed, with a comment claiming the check happens "500ms after the
pane's last touch (i=6, at 1_006_000ms)".
That comment doesn't match the loop:
0..8runs 8 iterations (i = 0..7), sothe actual last touch lands at i=7 → wall-clock 1_007_000ms, one whole
second later than the comment says. All 8 touches finish before
flush_all()is called even once, so the "mid-ingest" flush described inthe comment isn't really mid-ingest — it's post-ingest, with the fake clock
wound backward 500ms to construct the assertion. I confirmed this by
temporarily adding a debug print inside the wall-clock-fallback loop in
flush_alland re-running the test:deltais negative — the assertion passes because the flush is (fictionally)timestamped before the last touch, not because it's exercising a
"500ms-since-last-touch, still fresh" boundary. So the test passes, but not
for the reason its own comments describe.
Step 2 — fixing the test's loop bound surfaced a second, unrelated failure
To make the test actually call
flush_all()mid-ingest (matching thecomment's intent — and matching what I understood production to do via its
periodic flush timer), I split the loop: 7 touches, then the "still fresh"
flush check, then an 8th touch, then the final closing flush.
That restructured test fails:
28 = 1+2+...+7— the 8th touch's value is missing from the final mergedoutput. The pane wasn't force-closed (
sink.len()was 0 at the mid-ingestcheck, as expected) — the 8th touch's sample was silently dropped by a
completely different mechanism than the one this PR touches.
Step 3 — tracing where the 8th touch went
Traced it to the "late data" guard in
process_group_samples(
worker.rs:428):The 8th touch's timestamp is
0(all samples in this test share oneevent-time, deliberately, since that's the degenerate case #474 is about).
For it to be dropped as "late,"
state.previous_watermark_msmust havealready advanced past
allowed_lateness_msby the time it arrived — eventhough the event-time watermark should be frozen at
0the whole time,since every sample carries the same timestamp.
Looking at
flush_all(worker.rs:601-607):propagated_wmis read fromstate.previous_watermark_msitself, and theresult (
+1) gets written straight back into that same field — every timeflush_allruns, regardless of whether any new data arrived or thewall-clock fallback actually fired. So
previous_watermark_mscreepsforward by 1ms on every call to
flush_all, even during a fully idle tickwhere nothing happened.
Step 4 — this isn't a one-off;
flush_allruns continuously in productionengine.rs:216-229spawns a background timer that callsflush_all()onevery worker every
flush_interval_ms(default1000,config.rs:58), forthe entire lifetime of the pipeline — not just at shutdown. So the 1ms creep
described in Step 3 isn't confined to my test scenario; it happens on every
tick of that timer, the whole time a group has any open pane, whether or not
the group is receiving new data.
With the production default
allowed_lateness_ms = 5000(config.rs:56),a stagnant-timestamp pane's own new samples would start getting dropped as
"late" once the creep pushes
previous_watermark_mspastts + allowed_lateness_ms, i.e. roughly 5000 flush cycles ≈ 83 minutesof continuous ingest with a frozen timestamp. My test's helper sets
allowed_lateness_ms: 0(worker.rs:2870), which is why it only took oneflush cycle to manifest there.
This is the same symptom #474 originally described (silent row loss on a
bulk load whose rows share one event-time) but via a completely different
mechanism than the one PR #536 fixes, and it isn't mentioned anywhere in
that PR's design doc.
Step 5 — trying to understand why the
+1is there at allThe only comment on it is
// Effective watermark: max(group's own, global) + 1ms for boundary.(worker.rs:601). My first assumption was thatclosed_windows(window_manager.rs) requires the watermark to bestrictly greater than a window's end to close it, and that the
+1wasneeded to cross that boundary. I checked, and that's wrong — the doc comment
on
closed_windows(window_manager.rs:45-46) is explicit that it alreadyuses
>=:What
closed_windowsdoes have is a separate early-return guard(
window_manager.rs:50):if current_wm <= previous_wm { return Vec::new(); }— i.e. it requires forward progress (current_wmstrictlygreater than
previous_wm) just to enter the closing logic at all, distinctfrom the
>=boundary check inside.To test whether the
+1is load-bearing anywhere, I temporarily changedeffective_wm = propagated_wm.saturating_add(1)toeffective_wm = propagated_wm(no nudge) and ran the fullprecompute_engineunit suite(81 tests) plus the three related e2e tests
(
e2e_precompute_wall_clock_fallback_active_ingest,e2e_netflow_single_second,e2e_precompute_equivalence). Everythingpassed, including the restructured mid-ingest test from Step 2. I reverted
this change immediately after — it was only a local experiment to answer
the question, not a proposed fix.
So, as far as I can tell from the test suite, the
+1isn't protecting anycurrently-tested behavior — the two real cases that need forward progress on
an otherwise-idle tick (the wall-clock fallback's own
force_tovalue, anda slower worker catching up to a faster worker's globally-propagated
watermark) both already produce a value strictly greater than
state.previous_watermark_mson their own, without help from the+1.What I'm confused about / asking here
I don't have a confident answer for why the
+1exists, or whether there'sa scenario (maybe not covered by the current test suite, maybe involving
some interaction I'm not seeing between multi-worker coordination and window
alignment) where it actually matters. I also don't know whether the
83-minute-creep-into-late-data-drop behavior described in Steps 3-4 is a
known, accepted characteristic of this engine that just isn't written down
anywhere, or a genuine gap in the #474 fix. I'd like someone who knows this
engine's watermark/windowing design better than I do to weigh in on both
before anyone (myself included) tries to change anything here.