Skip to content

refactor(backpressure): reduce allocations in LatestValuesAccumulator#2661

Open
NearlCrews wants to merge 1 commit into
SignalK:masterfrom
NearlCrews:refactor/backpressure-tidy-accumulator
Open

refactor(backpressure): reduce allocations in LatestValuesAccumulator#2661
NearlCrews wants to merge 1 commit into
SignalK:masterfrom
NearlCrews:refactor/backpressure-tidy-accumulator

Conversation

@NearlCrews

@NearlCrews NearlCrews commented May 6, 2026

Copy link
Copy Markdown
Contributor

Why

LatestValuesAccumulator runs on the per-delta path while a client is in backpressure mode. The accumulation loop and the flush group-by both had small allocation and lookup inefficiencies that are worth tidying up.

This is the LatestValuesAccumulator.ts portion of the now-redundant #2648 (the streambundle.ts portion was superseded by #2622, which landed afterward). Reopened as a focused PR.

What's in the diff

accumulateLatestValue:

  • Hoist context, $source, sourceKey, and timestamp out of the per-pathvalue inner loop. One read per update instead of one read per pathvalue.

buildFlushDeltas:

  • Replace the double Map lookup (.has(k) + .get(k) + .set(k, ...)) with a single get-or-create pattern.
  • Drop a tautological hasValues(update) check that runs immediately after constructing the update with values: [].
  • Tighten the inner Map type to Update & { values: PathValue[] } so the initial { values: [] } is correctly typed and the per-pathvalue push no longer needs as Path / as any casts.
  • Replace the per-value cast from any to Value (from @signalk/server-api).

Cleanup:

  • Add UNKNOWN_SOURCE = 'unknown' module constant for the two places that fall back when $source is missing.
  • Drop a few WHAT-leaning JSDoc and inline comments.

Notes

  • All 34 tests in test/LatestValuesAccumulator.js and test/BackpressureManager.ts pass.
  • No behavior change to the per-delta accumulation or the flushed delta shape.
  • No version bump per AGENTS.md.

Refs #2582.

Summary

This PR refactors LatestValuesAccumulator to reduce allocations and lookup inefficiencies on the per-delta path during client backpressure.

Changes in src/LatestValuesAccumulator.ts

  • accumulateLatestValue:
    • Hoists repeated reads (context, $source, sourceKey, timestamp) out of the per-pathvalue inner loop so each update does one read instead of one per pathvalue.
    • Uses a get-then-mutate-or-create pattern to reuse and mutate existing AccumulatedItem on overwrite (mutating value, $source, timestamp only; context/path remain invariant), avoiding allocating a fresh AccumulatedItem on every set().
  • buildFlushDeltas:
    • Replaces double Map lookup (.has + .get + .set) with a get-or-create pattern.
    • Removes a tautological hasValues(update) check that followed constructing update with values: [].
    • Tightens inner Map typing so update.values is typed as PathValue[], eliminating casts.
    • Replaces per-value any→Value cast with Value from @signalk/server-api.
    • Preserves logic that selects the newest timestamp per (context, $source) before emitting deltas.
  • Module-level:
    • Adds UNKNOWN_SOURCE = 'unknown' constant as a fallback when $source is missing.
    • Restores JSDoc on the two exported functions (accumulateLatestValue and buildFlushDeltas) while removing redundant inline comments.

No behavioral change to per-delta accumulation or flushed-delta shape is introduced. No version bump.

Changes in test/LatestValuesAccumulator.js

  • Adds three tests:
    • Two tests that exercise timestamp comparison for two items with the same context and $source but different paths and timestamps, covering both insertion orders and asserting the later timestamp is preserved.
    • One test asserting that an accumulated item with timestamp: undefined produces a flushed delta with timestamp: undefined.

Testing

  • LatestValuesAccumulator tests pass locally (16/16 in test/LatestValuesAccumulator.js: 13 existing + 3 new).
  • The PR does not introduce other CI failures (an unrelated Node 24 ESM failure exists on master).

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@NearlCrews, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 28 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: beb81ce8-e50f-453a-ac6a-8e171c4963c2

📥 Commits

Reviewing files that changed from the base of the PR and between 7a7739c and 8804ad9.

📒 Files selected for processing (2)
  • src/LatestValuesAccumulator.ts
  • test/LatestValuesAccumulator.js
📝 Walkthrough

Walkthrough

Refactors LatestValuesAccumulator to centralize UNKNOWN_SOURCE, key accumulator entries by (context, path, source) and mutate them in-place, and rewrites buildFlushDeltas to group accumulated values by context and normalized $source before emitting backpressure deltas; adds a test for undefined timestamps.

Changes

Accumulation and Flush Refactoring

Layer / File(s) Summary
Setup and accumulation logic
src/LatestValuesAccumulator.ts
Adjusts type imports and adds UNKNOWN_SOURCE constant; normalizes missing $source values used for keying.
accumulateLatestValue in-place update
src/LatestValuesAccumulator.ts
Generates composite keys from delta.context, pv.path, and normalized $source; mutates existing accumulator entries' value, $source, and timestamp instead of always creating new Map entries.
Flush delta grouping and emission
src/LatestValuesAccumulator.ts
Rewrites buildFlushDeltas to use nested maps keyed by context then normalized $source, groups Update objects with typed values, selects newest timestamp per (context, $source), and emits one backpressure delta per context.
Undefined timestamp propagation test
test/LatestValuesAccumulator.js
Adds a test ensuring an accumulated item with timestamp: undefined produces a flushed delta update whose timestamp remains undefined.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Poem

I nibble on code with a hop and a twitch,
Unknown sources gathered into one small stitch,
Keys woven from context, path, and source,
Flushed in groups along a tidy course,
A rabbit's small patchwork of logic, neatly stitched. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main refactoring focus: reducing allocations in LatestValuesAccumulator through various optimization techniques.
Description check ✅ Passed The description comprehensively addresses both required template sections: it clearly articulates the problem (allocation/lookup inefficiencies in backpressure handling) and details how the changes were tested with all 34 tests passing and no behavioral changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@msallin msallin 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.

@msallin

msallin commented May 6, 2026

Copy link
Copy Markdown
Contributor

Thanks for picking this up @NearlCrews, the type tightening and the dead-hasValues removal are clean wins.

A couple of things to revise before this is ready:

  • Please keep the JSDoc on the two exported functions. The @param / @returns blocks document the contract at the module boundary, which is a different category from the WHAT-vs-WHY split that AGENTS.md targets. Happy to lose the inline "Group by context" / "Build one delta per context" comments since those just restate the next line.
  • The buildFlushDeltas timestamp comparison branch (item.timestamp && (!update.timestamp || item.timestamp > update.timestamp)) is currently unexercised. The existing "latest value wins" tests cover accumulateLatestValue's Map keying, so they never feed two items with the same context+source to buildFlushDeltas. Could you add a direct test that seeds an accumulator with two items same context+$source but different paths and timestamps, and asserts the produced update.timestamp is the later one (plus a reverse case so the comparison can't silently regress)?
  • Same area: please add a test where item.timestamp is undefined — the patch drops the as Timestamp cast so undefined now propagates, and that path also has no coverage.

If you want to fold in one more allocation win while you're here: accumulateLatestValue still allocates a fresh AccumulatedItem on every set() even when the key already exists, and overwrites are the common case during sustained backpressure. A get-then-mutate-or-create avoids the per-pathvalue object allocation. Optional, fine to defer if you'd rather keep the PR scoped.

@tkurki happy to recommend merging once the JSDoc is back and the two buildFlushDeltas tests are added.

@NearlCrews NearlCrews force-pushed the refactor/backpressure-tidy-accumulator branch from 5803f9f to c75e74a Compare May 8, 2026 13:13
@NearlCrews

Copy link
Copy Markdown
Contributor Author

@msallin all four addressed in the latest commit:

  • JSDoc restored on accumulateLatestValue and buildFlushDeltas. The inline // Group by context / // Build one delta per context removals stay.
  • buildFlushDeltas timestamp-comparison branch covered both directions:
    • should use the latest timestamp across items with same context+$source (newer item set last, exercises the true branch of item.timestamp > update.timestamp).
    • should use the latest timestamp regardless of accumulator insertion order (newer item set first, exercises the false branch). Locks in the comparison.
  • should propagate undefined timestamp when items have no timestamp exercises the dropped as Timestamp cast path.
  • Optional get-then-mutate: included. accumulateLatestValue now reuses the existing AccumulatedItem on overwrite (mutates value / $source / timestamp only; context / path are invariant for a given key by construction). Saves one object per pathvalue per overwrite during sustained backpressure.

16/16 in test/LatestValuesAccumulator.js (13 existing + 3 new).

Comment thread test/LatestValuesAccumulator.js Outdated
expect(deltas[0].$backpressure.duration).to.equal(2000)
})

it('should use the latest timestamp across items with same context+$source', function () {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I know this is not a change introduced by this PR, but the test and thus the current behavior is imho incorrect: the timestamp for an update is/should be the timestamp for all values in the update, NOT the timestamp of the freshest value in it.

So afai understand we are sending incorrect timestamps for some pathvalues, when they get grouped by context & $source. Am I reading things right here @dirkwa ?

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.

@tkurki yes, you're reading it right. Walking through a concrete case to make sure we're talking about the same thing:

Two frames arrive during backpressure from $source: gps:

  • T=10.000: { timestamp: '10.000', values: [{path: 'navigation.speedOverGround', value: 5.0}] }
  • T=10.005: { timestamp: '10.005', values: [{path: 'navigation.headingTrue', value: 1.5}] }

accumulateLatestValue keys per context:path:$source, so each lands in its own accumulator entry with its original timestamp — correct so far.

buildFlushDeltas then regroups by context + $source only, merging both pathvalues into one Update and keeping the max timestamp. Result:

$source: 'gps',
timestamp: '10.005',
values: [
  { path: 'navigation.speedOverGround', value: 5.0 },  // actually sampled at 10.000
  { path: 'navigation.headingTrue',    value: 1.5 }   // actually sampled at 10.005
]

A downstream consumer is told speedOverGround=5.0 @ 10.005. Per the delta spec the update's timestamp applies to every pathvalue in it (no per-pathvalue timestamp field), so we are emitting incorrect timestamps for any pathvalue whose original frame timestamp differs from the newest one in the group.

This is pre-existing — the comparison is in the file before this diff; this PR just relocates it and unfortunately adds tests that lock the wrong behavior in.

Two possible fixes for the follow-up:

  1. Group by context + $source + timestamp so pathvalues that originally shared a timestamp ride in one update and the rest go in their own. Preserves spec semantics exactly. Slight cost: more updates per flush when a source emits paths at slightly different timestamps (common for N2K PGNs demuxed into multiple paths) — probably fine since backpressure flushes are infrequent by definition.
  2. Group by context + $source, drop timestamp (or set it to flush time). Honest "summarized snapshot" semantics, loses original-sample freshness.

I'd go with (1).

Proposal for this PR, to keep it as the scoped refactor it claims to be: I'll drop the two new tests that assert the max-timestamp behavior (should use the latest timestamp across items with same context+$source and the insertion-order variant) so we don't entrench the bug, keep the undefined-timestamp test (that one's about the dropped as Timestamp cast and is independent), and open a separate issue for the grouping fix. WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@tkurki @dirkwa your reading of src/LatestValuesAccumulator.ts:113-119 matches mine: the merged update's timestamp is whichever of the merged items had the freshest one, and the spec says that timestamp applies to every pathvalue in the update. So yes, this emits incorrect timestamps for any merged pathvalue that originally arrived in an older frame. Pre-existing: the merge-and-max-timestamp logic was already there before this PR; this PR only restructures the loop.

Agree with option 1 (context + $source + timestamp grouping). The N2K-demuxed-into-multiple-paths case you mention usually shares a timestamp at source, so it'll still coalesce in practice. The extra-updates cost only shows up when timestamps genuinely differ, which is exactly when keeping them separate is the right thing.

Dropped the two new tests that locked in the max-timestamp behavior (should use the latest timestamp across items with same context+$source and the insertion-order variant) and kept the undefined-timestamp test, which is independent. Pushed as aeeeaaf4. Commit message updated to match.

Filed #2716 for the grouping fix.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/LatestValuesAccumulator.js`:
- Around line 330-345: The new JavaScript test in LatestValuesAccumulator.js
must be converted to TypeScript: create a new file
LatestValuesAccumulator.test.ts (or move the block) and port the `it('should
propagate undefined timestamp when items have no timestamp'...)` test to
TypeScript syntax, updating imports/requires to use ES modules or TS imports
consistent with the test suite, ensure types for the accumulator Map entry (or
use `as any`/typed helpers) and keep the call to `buildFlushDeltas(accumulator,
1000)` and the same expectations (`deltas[0].updates[0].timestamp` ===
undefined); remove the .js test file after verifying the TS test runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 07ea7a06-c18a-4aae-b29c-999960931058

📥 Commits

Reviewing files that changed from the base of the PR and between 396fc5c and aeeeaaf.

📒 Files selected for processing (2)
  • src/LatestValuesAccumulator.ts
  • test/LatestValuesAccumulator.js

Comment on lines +330 to +345
it('should propagate undefined timestamp when items have no timestamp', function () {
const accumulator = new Map()
accumulator.set('vessels.self:navigation.speedOverGround:gps', {
context: 'vessels.self',
path: 'navigation.speedOverGround',
value: 5.0,
$source: 'gps',
timestamp: undefined
})

const deltas = buildFlushDeltas(accumulator, 1000)

expect(deltas.length).to.equal(1)
expect(deltas[0].updates.length).to.equal(1)
expect(deltas[0].updates[0].timestamp).to.equal(undefined)
})

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move this new test case to TypeScript.

This PR adds new test logic in a .js file; per repo policy, new code should be added as TypeScript.

As per coding guidelines: **/*.{js,jsx}: “All new code must be written in TypeScript, not JavaScript”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/LatestValuesAccumulator.js` around lines 330 - 345, The new JavaScript
test in LatestValuesAccumulator.js must be converted to TypeScript: create a new
file LatestValuesAccumulator.test.ts (or move the block) and port the
`it('should propagate undefined timestamp when items have no timestamp'...)`
test to TypeScript syntax, updating imports/requires to use ES modules or TS
imports consistent with the test suite, ensure types for the accumulator Map
entry (or use `as any`/typed helpers) and keep the call to
`buildFlushDeltas(accumulator, 1000)` and the same expectations
(`deltas[0].updates[0].timestamp` === undefined); remove the .js test file after
verifying the TS test runs.

@NearlCrews NearlCrews force-pushed the refactor/backpressure-tidy-accumulator branch from aeeeaaf to 7a7739c Compare May 28, 2026 13:47
@NearlCrews

Copy link
Copy Markdown
Contributor Author

Rebased on current master (7a7739c). CI is red on the test/discovery.ts:24 ReferenceError under Node 24 ESM, but that's the master-wide regression. Same failure on master itself (run 26536340104), nothing from this PR. #2715 covers the fix. Local mocha test/LatestValuesAccumulator.js is 14/14 green.

Ready for review whenever master CI unblocks.

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

@msallin

msallin commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Triage / direction.

The allocation hoisting and the type tightening are good and I'm happy to take them. The blocker is the new timestamp tests: they lock in the buildFlushDeltas grouping that @tkurki and @dirkwa identified as spec-violating (the max timestamp in a context + $source group gets applied to every pathvalue, including ones originally sampled at a different time). We should not codify that.

Please split into two:

  1. This PR: keep the accumulateLatestValue hoisting, the get-or-create, and the type tightening (Update & { values: PathValue[] }, dropping the casts). Drop the three timestamp-grouping tests. Re-title as refactor, not perf, since the measurable win is small and the real value is the allocation/type cleanup.
  2. A separate correctness PR implementing dirkwa's option 1: group by context + $source + timestamp so pathvalues that shared a frame timestamp ride together and the rest get their own update. The timestamp tests belong there, asserting the correct behavior.

Once (1) is trimmed I'll recommend merge.

LatestValuesAccumulator runs on the per-delta path while a client is
in backpressure mode. The accumulation loop and the flush group-by
both had small allocation/lookup inefficiencies that are easy to clean
up.

Changes:
- Hoist context, $source, sourceKey, and timestamp out of the
  per-pathvalue inner loop in accumulateLatestValue (one read per
  update instead of one read per pathvalue).
- accumulateLatestValue mutates the existing AccumulatedItem on
  overwrite instead of allocating a fresh one. Common case during
  sustained backpressure; saves one object per pathvalue per overwrite.
- Replace the double Map lookup (.has(k) + .get(k)) in
  buildFlushDeltas with a single get-or-create pattern.
- Drop a tautological hasValues(update) check that runs immediately
  after constructing the update with values: [].
- Tighten the inner Map type to Update & { values: PathValue[] } so
  the initial { values: [] } is correctly typed and the per-pathvalue
  push no longer needs the as Path / as any casts.
- Replace the value cast from any to Value (from @signalk/server-api).
- Add UNKNOWN_SOURCE = 'unknown' constant for the two places that
  fell back when $source is missing.
- Drop a few WHAT-leaning inline comments. JSDoc on the two exported
  functions is kept.

New test in test/LatestValuesAccumulator.js:
- buildFlushDeltas propagates undefined timestamp when items have no
  timestamp (covers the dropped \`as Timestamp\` cast).

This is the LatestValuesAccumulator portion of the now-redundant
SignalK#2648 (the streambundle.ts portion was superseded by SignalK#2622, which
landed afterward).

Refs SignalK#2582.
@NearlCrews

Copy link
Copy Markdown
Contributor Author

Rebased onto master to clear the stale discovery.ts build failure (fixed upstream in 0f6c907).

On the timestamp tests: the diff currently adds only one, should propagate undefined timestamp, and it seeds a single-entry accumulator, so it asserts that an undefined timestamp propagates after dropping the as Timestamp cast, not the context + $source max-timestamp grouping that you and @dirkwa flagged. The two grouping tests are not in the diff, and the title is already refactor(backpressure).

So this already looks trimmed to the allocation hoisting, the get-or-create, and the type tightening. Do you want that one undefined-timestamp test dropped as well, or is it fine to keep since it does not lock in the grouping behavior? Happy to move it to the follow-up correctness PR if you would rather all timestamp tests live there.

@NearlCrews NearlCrews force-pushed the refactor/backpressure-tidy-accumulator branch from 7a7739c to 8804ad9 Compare June 24, 2026 01:49
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.

4 participants