fix(precompute): close idle/trailing windows for one-shot batch ingest (wall-clock fallback + shutdown force-close)#400
Conversation
… idle windows Sync the precompute-engine window-closing fix from ASAPQuery-backend. Problem: with strict event-time semantics, a tumbling window [T, T+size) only closes when the watermark reaches T+size. For a one-shot batch ingest where every record carries (nearly) the same timestamp, the watermark freezes and flush_all's +1ms advance is a no-op, so the trailing window never closes — emit_batch is never called and the store stays empty even though data was ingested. Fix (ported from the backend's flush_all "sweep blocker #2" fallback): track each pane's wall-clock birth time and, in flush_all, force the effective watermark past pane_start + window_size_ms for any pane that has been alive longer than window_size_ms + wall_clock_grace_period_ms of wall-clock time, regardless of event-time. Set wall_clock_grace_period_ms <= 0 to opt out and keep strict event-time-only semantics. Changes: - config: add wall_clock_grace_period_ms (serde default 5000ms) - worker: GroupState.pane_wall_clock_starts_ms + prune; injectable now_ms_fn (default SystemTime::now, test override via set_now_ms_fn); record pane birth in process_group_samples; wall-clock fallback in flush_all - engine/main/CLI: thread the setting through (PrecomputeSettings + --wall-clock-grace-period-ms flag) - tests: wall_clock_fallback_closes_idle_window and the grace=0 opt-out; update existing config literals Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the wall-clock grace fallback. The fallback only fires once a pane has aged window_size + grace of wall-clock time on a flush tick, so a one-shot batch that ingests and immediately shuts down still leaves its trailing window open — flush_all's +1ms advance never reaches the window end and the data never lands in the store. Add Worker::force_close_all(), invoked from the Shutdown handler after the final flush_all(): for every group with open panes, advance to a finite bound (max_pane + window_size_ms) and emit every remaining window. A finite bound is used deliberately — WindowManager::closed_windows enumerates window starts one slide at a time, so passing i64::MAX would loop ~i64::MAX/slide times and overflow. The pass is idempotent: drained panes are removed and their wall-clock bookkeeping pruned. - worker: force_close_all() + Shutdown handler wiring - tests: shutdown_force_close_emits_trailing_window; update test_update_agg_configs_enables_new_aggregation_at_runtime to assert the trailing window [10_000, 20_000) is now emitted (previously lost) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ngest Drives the real JsonFileIngestSource -> PrecomputeEngine -> sink path with a synthesized NetFlow JSONL whose records all fall in the same second. Sets wall_clock_grace_period_ms=0 so only the shutdown force-close can close the trailing window — a non-empty sink proves the force-close rescues the batch. Verified both directions: with the Shutdown force-close the single-second window is emitted with the summed bytes; with it disabled the run produces 0 outputs (reproducing the reported empty-store bug). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-fallback # Conflicts: # asap-query-engine/src/precompute_engine/worker.rs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
milindsrivastava1997
left a comment
There was a problem hiding this comment.
The PR's own docstring for force_close_all explains why it avoids i64::MAX (closed_windows would loop ~i64::MAX/slidetimes and overflow/hang). But there's a sibling code path with that exact bug, untouched by this PR: in theUpdateAggConfigs handler (worker.rs:206, pre-existing, not in this diff), the removed-agg-id cleanup calls:
let closed = state.window_manager.closed_windows(state.previous_watermark_ms, i64::MAX);
…ce-close Addresses review feedback on #400. The removed-agg-id cleanup in the UpdateAggConfigs handler advanced the watermark to i64::MAX, the exact hazard force_close_all's docstring warns about: closed_windows enumerates window starts one slide at a time, so with a real epoch-ms watermark it loops ~i64::MAX/slide times and overflows `start + window_size_ms` (panics in debug). Use the same finite bound as force_close_all (max_pane + window_size_ms). Add test_update_agg_configs_removed_id_force_closes_at_epoch_ms_timestamps, which opens a window at a realistic epoch-ms timestamp and removes the agg_id; it hangs/overflows under the old i64::MAX code (verified: times out) and passes with the finite bound. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Good catch — fixed in 23e2bde. The let closed = match active_panes.keys().next_back() {
Some(&max_pane) => {
let force_wm = max_pane.saturating_add(state.window_manager.window_size_ms());
state.window_manager.closed_windows(state.previous_watermark_ms, force_wm)
}
None => Vec::new(), // no open panes
};I also added |
Syncs the precompute-engine window-closing fixes from ASAPQuery-backend into ASAPQuery, and adds a shutdown force-close so a one-shot batch can never lose its trailing window.
Problem
With strict event-time semantics, a tumbling window
[T, T+size)only closes when the watermark reachesT+size. For a one-shot batch ingest where every record carries (nearly) the same timestamp — e.g. NetFlow records that all fall in the same second — the per-group watermark freezes atmax_ts < T+size.flush_allonly advances by+1ms, soclosed_windows(prev, prev+1)returns empty forever: the trailing window never closes,emit_batchis never called, and the store stays empty even though data was ingested.Commit 1 — port the wall-clock grace fallback (backend → frontend)
Track each pane's wall-clock birth time; in
flush_all, force the effective watermark pastpane_start + window_size_msfor any pane alive longer thanwindow_size_ms + wall_clock_grace_period_msof wall-clock time, regardless of event-time.wall_clock_grace_period_ms <= 0opts out (strict event-time only). Default 5000ms, matching the backend.config: addwall_clock_grace_period_ms(serde default 5000)worker:GroupState.pane_wall_clock_starts_ms+ prune; injectablenow_ms_fn(set_now_ms_fnfor tests); record pane birth inprocess_group_samples; fallback block inflush_allengine/main/CLI: thread the setting through (PrecomputeSettings+--wall-clock-grace-period-ms)Commit 2 — force-close open windows on shutdown
The fallback is wall-clock gated, so a batch that ingests and immediately shuts down hasn't aged past the grace period and still leaves its trailing window open.
Worker::force_close_all()(called from theShutdownhandler after the finalflush_all) closes every remaining open window unconditionally, since no further samples will arrive.It advances to a finite bound (
max_pane + window_size_ms), noti64::MAX:WindowManager::closed_windowsenumerates window starts one slide at a time, soi64::MAXwould loop ~i64::MAX/slidetimes and overflow. Idempotent — drained panes are removed and their bookkeeping pruned.Tests
wall_clock_fallback_closes_idle_window,wall_clock_fallback_disabled_preserves_event_time_only_semanticsshutdown_force_close_emits_trailing_windowtest_update_agg_configs_enables_new_aggregation_at_runtimeupdated: the trailing window[10_000, 20_000)is now emitted on shutdown (previously silently lost)Verification
cargo check --lib --tests --bins— cleanprecompute_engine::worker— 28 passed ·precompute_engine::config— passedengine_config— 20 passed ·e2e_precompute_equivalence— 2 passed🤖 Generated with Claude Code