Repository navigation
Resume analytics aggregation from the last raw record it rolled up - #2692
Conversation
`aggregation()` rolls up one `toPeriod` window of `hdb_raw_analytics` per cycle and then sets `lastAggregationTime` to the cycle's end-of-run `Date.now()`. The next cycle resumes from that marker, exclusive, so every raw report between the end of the window and the end of the cycle was skipped for good: the remainder of a backlog left by a late tick, and anything recorded while the cycle itself was running. The marker is now `lastTime ?? cycleStart` — the last raw key the cycle actually consumed, or the time the cycle began when the window was empty, which cannot skip a record because none exists after the marker and every later report carries a higher key. While the marker is behind, the cadence guard stops rejecting ticks, so each half-period tick drains one more window until it catches up. That catch-up makes overlapping cycles likelier, and `setInterval` does not await its async callback, so cycles now run under a single-flight flag: two cycles reading the same marker roll the same window up twice and double every count in it. `unitTests/resources/analytics/aggregationCycle.test.js` drives the real cycle over a seeded backlog: on the previous marker rule the second and third windows are never aggregated, and without the flag the concurrent pair stores the same window twice. Refs https://github.com/HarperFast/harper-pro/actions/runs/35429157113 Dispatch-Task: main-red-kriszyp_harper-pro_8b570b7db_f0efc59e Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s dead cadence wait Pre-push review, all three finding legs: `cycleStart` was `Date.now()` while raw records are keyed by `getNextMonotonicTime()`, whose wall-clock calibration is refreshed only every 60 s. A raw key can therefore sit below a `Date.now()` read taken after it — and a clock step in either direction either skips records or re-aggregates them. Both the cycle and `recordAnalytics` run on the main thread, so taking `cycleStart` from the same sequencer makes every later key strictly higher. The regression test waited a period between cycles for a cadence guard that a held-back marker already leaves open; without the wait it asserts the drain the fix is for. Its bootstrap `recordAction` also starts the production scheduler, which shares the marker and the single-flight flag, so the period is pinned to an hour first. Dispatch-Task: main-red-kriszyp_harper-pro_8b570b7db_f0efc59e Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request addresses an issue where raw analytics records could be skipped during aggregation cycles by transitioning the aggregation marker from a wall-clock timestamp to a raw-record cursor (lastTime ?? cycleStart). It introduces a single-flight guard (runAggregationCycle) to prevent overlapping cycles, updates the scheduled tasks to use this guard, and adds comprehensive design documentation and unit tests to verify the backlog aggregation and concurrency prevention. There are no review comments to evaluate, and we have no additional feedback to provide.
Pre-push review round 2: `recordAnalytics` does not await its `primaryStore.put`, so a raw report can be uncommitted — and invisible to the scan — while carrying a key below any clock reading the cycle takes. Advancing the resume point to the cycle's start after an empty scan therefore skipped that report for good, which is the same defect this branch is fixing. So the one marker becomes two. `rawCursor` is the resume point into `hdb_raw_analytics` and moves only to a record a cycle read, never to a clock; `lastAggregationTime` goes back to being the cadence marker stamped at the end of every completed cycle, so #1538's idle behavior is untouched. The catch-up drain that the shared marker used to provide implicitly is now explicit: a cycle that stops at its window edge sets `aggregationBehind`, and the next one skips the cadence guard. The regression suite gains the branch that motivated this: a cycle that reads nothing, a report seeded behind it, and a cycle that must still aggregate it. Dispatch-Task: main-red-kriszyp_harper-pro_8b570b7db_f0efc59e Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hosen row Pre-push review round 3 nits: the approaches table still named the superseded `cycleStart` fallback, the seed comment mentioned one marker where two are now seeded, and two phrases described the change rather than the code. Dispatch-Task: main-red-kriszyp_harper-pro_8b570b7db_f0efc59e Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The round-3 edit to the approaches table never applied — the table had been reformatted with aligned pipes, so the replacement matched nothing and the row still named the superseded `cycleStart` fallback. The seed comment also claimed both markers are stamped at the end of a cycle; only the cadence one is, since an empty scan leaves the cursor alone. Dispatch-Task: main-red-kriszyp_harper-pro_8b570b7db_f0efc59e Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-push review round 5: the recording path creates `hdb_raw_analytics` before its own unawaited `put` is visible, so `lastRawKey()` could return undefined and the first test would seed at `NaN`. Dispatch-Task: main-red-kriszyp_harper-pro_8b570b7db_f0efc59e Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed; no blockers found. |
Release cherry-pick
|
Keep v5.2 design notes intact and append the final raw-cursor invariant from #2692. Drop unrelated main-branch notes pulled into the nested conflict; retain the cleanly cherry-picked code, tests, and original design history. Dispatch-Task: cherry-resolve-kriszyp_harper_2692-ed0adf7e Co-Authored-By: GPT-5 Codex <noreply@openai.com>
An analytics aggregation cycle rolls up one
analytics.aggregatePeriodwindow ofhdb_raw_analyticsat a time, and it used to resume from a marker stamped with the cycle's own end-of-runDate.now(). Because the next cycle starts after that marker, every raw report between the end of the window and the end of the cycle was discarded: the remainder of a backlog left by a late tick, and anything a worker reported while the cycle was running.get_analyticsunder-counted by whatever fell in the gap, and the lost samples never came back.The resume point is now
rawCursor, which moves only to a record a cycle actually read — never to a clock — and stays where it is when a cycle reads nothing.lastAggregationTimegoes back to being only the cadence marker, so an idle node still aggregates once per period.For the human reviewer
maingoing red on harper-pro at 8b570b7db — one leg,Cluster Integration Tests 5/6 (Node.js v22), inintegrationTests/cluster/replicatedAnalyticsUnion.test.mjs. The same shard passed on Node 24 and 26.5.0 at that SHA, so the cheap reading was "flake, rerun it". The trace says otherwise: that test writes five records and then waits 45 s for onedb-writeaggregate row, and when the raw report carrying those writes lands in the dropped gap, no later cycle can produce it. Rerunning would have turned CI green and left a metrics-loss bug in place, so the fix is warranted; whether it must ship as part of gettingmaingreen, rather than on its own schedule, is the call to make.better-alternative-existsand asked for a cursor persisted inhdb_analyticsatomically with the rows derived from each window. Half of that alternative is adopted here — cadence and raw progress are separate — but not the durable half. Two cases therefore stay open: a restart mid-drain reseeds from an end-of-cycle stamp and skips the rest of the backlog, andstoreMetricdiscardstable.put()'s result, so a failed aggregate write still advances the cursor. I overruled the durable half for this change becausehdb_analyticsis user-visible —get_analyticsreads it, metric discovery enumerates it, and the replicated union fan-out concatenates it across peers — so a new record shape there is API surface that needs its own design round, and neither case is what reddened CI. Both are recorded in DESIGN.md and the adjudicator classed them as pre-existing. Reversing this means a second PR, not a revert.aggregationBehind, and the next cycle runs without waiting out the period, so a stall of S seconds clears in about S seconds. Keeping the cadence instead means a backlog never drains; rolling the whole backlog up in one cycle gives up the bounded main-thread decode that #1538 exists to keep. The cost is that while draining, the per-cycle metrics (resource usage, DB sizes, RocksDB stats) are written twice as often. One line to reverse.await, the process-level handler logs rather than exits, and the cursor is not advanced on a throw, so the window is retried. It belongs with the durable-cursor work, not here.runAggregationCycleis a new module export. The regression tests drive the real cycle rather than a helper, which is what makes them fail on the previous code; the cost is an exported function that is harder to take back later than an internal one.findLastAggregationTimewas exported for the same reason in #1541.Changes
resources/analytics/write.ts— the scan resumes fromrawCursorrather than from the cadence marker, and the cursor is only assigned from a key the scan read. An empty scan leaves it alone, which matters becauserecordAnalyticsdoes not await itsprimaryStore.put: a report can be uncommitted, and therefore invisible to the scan, while carrying a key below any clock reading the cycle could take. Cycles also run under a single-flight flag, sincesetIntervaldoes not await its async callback and two cycles reading the same cursor roll the same window up twice — a real exposure now that catch-up keeps the cadence guard open.unitTests/resources/analytics/aggregationCycle.test.js— three properties over the real cycle: an empty cycle does not pass a report seeded behind it, a backlog longer than one window drains across consecutive cycles, and a cycle entered while another runs does not re-aggregate the window.DESIGN.md— the two cursor rules, the no-overlap rule, and the losses that remain open.docs/design/analytics-aggregation-resume-marker.md— the design note, its spanning set, and the planning- and review-round dispositions.Verification
Core unit gate
npm run test:unit:resources: 2879 passing, 46 pending, 3 failing —unitTests/resources/conditionDeleteVisibility.test.js(×2) andunitTests/resources/rangeReadActivity.test.js. All three fail identically withresources/analytics/write.tsreverted to its base revision in the same sandbox, whose borrowednode_modulescarries rocksdb-js 2.8.0 where this checkout asks for 2.9.1.Fails-on-base, each with a rebuild between swaps because the tests load
dist:lastAggregationTimemarker → all three tests fail;refuses a cycle that starts while another is runningfails withAggWindowConcurrentstored twice.End to end: harper-pro's
integrationTests/cluster/replicatedAnalyticsUnion.test.mjsagainst adistbuilt from this core, through the companion PR's CI. That test reproduces the defect at roughly one run in 25, so it corroborates rather than proves; the unit-level fails-on-base checks are the mechanism proof.Complexity: medium
Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok; rounds=6; full=2 @ 3c12595
Human-Review-Need: 3 (decisions: in-memory-cursor, catch-up-bypasses-cadence, cold-start-seed, test-export) @ 3c12595