Skip to content

perf(client): normalize streamed rows in a single pass (#229) - #230

Merged
fupelaqu merged 2 commits into
mainfrom
perf/scroll-row-normalization
Aug 12, 2026
Merged

perf(client): normalize streamed rows in a single pass (#229)#230
fupelaqu merged 2 commits into
mainfrom
perf/scroll-row-normalization

Conversation

@fupelaqu

Copy link
Copy Markdown
Contributor

Closes #229

Problem

ScrollApi.scroll re-normalized every row on the stream with per-row collection work measured at ~7% of total sidecar CPU on the arrow#160 benchmark (post-#227 image, J2 = JOIN + GROUP BY over an 11M-row extraction): Set$Set3.contains 3.91% (top non-Arrow, non-Jackson method), plus StrictOptimizedIterableOps.filterImpl and ListMapBuilder allocations. Per row the old code did:

  1. fields.map(f => row.getOrElse(f, null)) — each get a linear scan of the ListMap ⇒ O(cols²) String.equals;
  2. row.filterNot(...) — a full second walk with a Set.contains per key;
  3. ListMap(ordered: _*) ++ extra — a fresh ListMap build plus O(n) appends —

for rows that in the common case already carry exactly the requested keys. The same shape existed in normalizeRow, called per row from the window-enrichment stream, the window one-shot loop, and the parse path's final rows.map.

Fix

New ElasticConversion.rowNormalizer(requestedFields) — built once per stream/loop, applied per row:

  • Hoisted per stream: the field-order array, a name → position java.util.HashMap, the EntityContext decision, and a duplicate-name check (degenerate duplicate output names fall back to the legacy normalizeRow semantics).
  • Single walk per row: entries split into a positional array (requested fields) and an ordered extras buffer; the output ListMap is built once.
  • Zero-allocation passthrough: a row already carrying the requested fields in order (extras may follow, already in final position) is returned as the same instance — no rebuild at all. An in-order strict prefix under EntityContext (skip-missing semantics) also passes through.
  • Output contract unchanged: requested fields first, in SQL SELECT order — missing ones null-filled (or skipped under EntityContext) — then the row's extra entries in their original order.

Rewired call sites (normalizer hoisted at each): ScrollApi.scroll stream normalization (the #229 site), ScrollApi.scrollWithWindowEnrichment per-row map, SearchApi.enrichResponseWithWindowValues loop, and the parse path's final rows.map. normalizeRow is unchanged and remains the single-row entry; its scaladoc now warns loops/streams to hoist a rowNormalizer.

Tests

  • New RowNormalizerSpec (core, no Docker): SELECT-order restoration, null-fill vs EntityContext skip, extras ordering (leading/interleaved), present-but-null kept in both contexts, empty row/fields, passthrough identity (theSameInstanceAs), duplicate-fields fallback, and a shape battery asserting .toList equality with the legacy normalizeRow in both contexts.
  • Guard suites green on real ES via Docker: ScrollCompletenessSpec, SelectCompletenessSpec, LimitCompletenessSpec, GroupByCompletenessSpec, WindowPartitionCompletenessSpec, WindowFunctionSpec, HitMetadataSpec on ES 6.8 (rest + jest), 7.17, 8.18, 9.0.
  • Core unit suite: 759/759. Cross-compile 2.12.20 + 2.13.16 clean. scalafmtCheckAll + headerCheck pass.

Context

Third leg of the arrow#160 extraction-cost campaign (with elasticsql #227/#228 and arrow #161): this is the SoftClient4ES-side residual identified in the post-#227 JFR profile.

🤖 Generated with Claude Code

fupelaqu and others added 2 commits August 12, 2026 14:44
ScrollApi.scroll re-normalized every row on the stream with O(cols^2)
ListMap scans (fields.map(row.getOrElse) linear gets, a full filterNot
second walk with a Set.contains per key, and a double ListMap rebuild)
- measured at ~7% of total sidecar CPU on the arrow#160 benchmark. The
same shape ran per row in normalizeRow on the window-enrichment stream,
the window one-shot loop and the parse path's final rows.map.

New ElasticConversion.rowNormalizer(requestedFields), built once per
stream/loop and applied per row:

- stream-constant work hoisted: field-order array, name -> position
  java.util.HashMap, EntityContext decision, duplicate-name detection
  (degenerate duplicate output names fall back to the legacy semantics
  via normalizeRowOrdered, with the name set still hoisted);
- a single walk per row, splitting entries into a positional array and
  an ordered extras buffer, with one ListMap build;
- zero-rebuild passthrough: a row already carrying the requested fields
  in order (extras may trail, already in final position) is returned as
  the same instance; an in-order strict prefix under EntityContext too.

Output contract unchanged: requested fields first in SQL SELECT order,
missing ones null-filled (or skipped under EntityContext), then the
row's extra entries in their original order.

New RowNormalizerSpec (15 tests) pins the contract, the passthrough
identities, the duplicate fallback and .toList equality with the legacy
normalizeRow across a shape battery in both contexts, including one
normalizer instance reused across a heterogeneous row stream. Guard
suites green on real ES 6.8 (rest+jest), 7.17, 8.18, 9.0; core 761;
cross-compiled 2.12 + 2.13.

Closes #229

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fupelaqu
fupelaqu merged commit 0896af2 into main Aug 12, 2026
3 of 4 checks passed
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.

ScrollApi.scroll re-normalizes every row on the stream with O(cols²) ListMap scans (~7% sidecar CPU at arrow#160 scale)

1 participant