Skip to content

Resume analytics aggregation from the last raw record it rolled up - #2692

Merged
kriszyp merged 6 commits into
mainfrom
fix/analytics-aggregation-resume-marker
Sep 20, 2026
Merged

kriszyp merged 6 commits into
mainfrom
fix/analytics-aggregation-resume-marker

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

An analytics aggregation cycle rolls up one analytics.aggregatePeriod window of hdb_raw_analytics at a time, and it used to resume from a marker stamped with the cycle's own end-of-run Date.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_analytics under-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. lastAggregationTime goes back to being only the cadence marker, so an idle node still aggregates once per period.

For the human reviewer

  1. Is this the right task at all? It came from main going red on harper-pro at 8b570b7db — one leg, Cluster Integration Tests 5/6 (Node.js v22), in integrationTests/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 one db-write aggregate 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 getting main green, rather than on its own schedule, is the call to make.
  2. The cursor stays in memory. The planning round returned better-alternative-exists and asked for a cursor persisted in hdb_analytics atomically 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, and storeMetric discards table.put()'s result, so a failed aggregate write still advances the cursor. I overruled the durable half for this change because hdb_analytics is user-visible — get_analytics reads 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.
  3. Catch-up skips the cadence guard. A cycle that stops at its window edge sets 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.
  4. A cycle's error still reaches the interval callback unhandled. Raised in three review rounds and deliberately left: base has the same unguarded 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.
  5. runAggregationCycle is 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. findLastAggregationTime was exported for the same reason in #1541.

Changes

resources/analytics/write.ts — the scan resumes from rawCursor rather 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 because recordAnalytics does not await its primaryStore.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, since setInterval does 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) and unitTests/resources/rangeReadActivity.test.js. All three fail identically with resources/analytics/write.ts reverted to its base revision in the same sandbox, whose borrowed node_modules carries 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:

  • scan and cursor reverted to the single lastAggregationTime marker → all three tests fail;
  • single-flight flag removed → refuses a cycle that starts while another is running fails with AggWindowConcurrent stored twice.

End to end: harper-pro's integrationTests/cluster/replicatedAnalyticsUnion.test.mjs against a dist built 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

kriszyp and others added 2 commits September 19, 2026 02:08
`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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

kriszyp and others added 4 commits September 19, 2026 02:52
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>
@kriszyp
kriszyp marked this pull request as ready for review September 20, 2026 01:24
@kriszyp
kriszyp merged commit d732c74 into main Sep 20, 2026
49 checks passed
@kriszyp
kriszyp deleted the fix/analytics-aggregation-resume-marker branch September 20, 2026 01:24
@claude

claude Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Release cherry-pick v5.2: conflict

Cherry-pick onto v5.2 produced conflicts on commit(s): 5dd2092442f79022337964ee209d3a2d674f92cd 823e5f0e895821b29789dd4307719af102dd045b

The conflict markers are committed on branch cherry-pick/v5.2/pr-2692.
A pull request has been opened to land this patch: #2987

kriszyp added a commit that referenced this pull request Oct 2, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant