Skip to content

Share FieldSpecs and repeated values across segment metadata loads - #19473

Open
xiangfu0 wants to merge 5 commits into
masterfrom
xiangfu0/data-3221-2-intern-parse
Open

Share FieldSpecs and repeated values across segment metadata loads#19473
xiangfu0 wants to merge 5 commits into
masterfrom
xiangfu0/data-3221-2-intern-parse

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

PR flow

Sharing FieldSpecs across segment loads by interning strings, canonicalizing defaults, freezing specs, and storing them in a weak interner.

flowchart TD
  N0["Parse and intern metadata strings #40;column#44; parent#44; datetime format#47;granularity#41; #40;F3#44; F4#41;"]:::stModified
  N1["Canonicalize default null literal #40;return null if equals type default#41; #40;F3#41;"]:::stModified
  N2["Create FieldSpec subclass instance with possibly null default #40;F3#41;"]:::stModified
  N3["Freeze the FieldSpec #40;setters throw#44; defensive copy mutable defaults#41; #40;F3#44; F6#44; F12#41;"]:::stModified
  N4["Intern frozen FieldSpec via weak interner keyed by equals#47;hashCode #40;F3#41;"]:::stModified
  N5["Store and expose shared FieldSpec in ColumnMetadata and Segment Schema #40;F3#44; F1#44; F2#44; F10#41;"]:::stModified
  N0 -->|"literal passed to canonicalizer"| N1
  N1 -->|"canonical default used in constructor"| N2
  N2 -->|"call freeze#40;#41; on spec"| N3
  N3 -->|"call intern#40;#41; on frozen spec"| N4
  N4 -->|"returned spec stored and accessed via getters"| N5
  classDef stAdded fill:#dafbe1,stroke:#1a7f37,color:#1f2328,stroke-width:2px
  classDef stModified fill:#fff8c5,stroke:#9a6700,color:#1f2328,stroke-width:2px
  classDef stRemoved fill:#ffebe9,stroke:#cf222e,color:#1f2328,stroke-width:2px
  classDef stUnchanged fill:#f6f8fa,stroke:#656d76,color:#1f2328,stroke-width:1px
Loading

AI-generated · Green: added · Yellow: modified · Red: removed · Gray: existing

Partial evidence: 0 file patches omitted; 1 truncated.

Diff evidence
  • F1: pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/ColumnMetadata.java — before · after
  • F2: pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/SegmentMetadata.java — before · after
  • F3: pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/metadata/ColumnMetadataImpl.java — before · after
  • F4: pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/metadata/SegmentMetadataImpl.java — before · after
  • F6: pinot-spi/src/main/java/org/apache/pinot/spi/data/FieldSpec.java — before · after
  • F10: pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/SegmentMetadataImplTest.java — before · after
  • F12: pinot-spi/src/test/java/org/apache/pinot/spi/data/FieldSpecFreezeTest.java — after
  • Regenerate PR flow

What

Each loaded segment retains its own column definitions and repeated metadata values. Metadata parsing now shares equal FieldSpec instances and the strings and default values they retain:

  • Reuse the type-default value when a default-null literal parses to an equivalent value, avoiding retention of the literal and a separate parsed value where a shared constant is available.
  • Intern column names (including the segment time-column name), parent-column names, date-time formats and granularities, and custom default-null literals.
  • Share equal DIMENSION, METRIC, TIME, and DATE_TIME specs through a thread-safe weak interner keyed by FieldSpec.equals/hashCode. Equal parsed column definitions can share across segments and tables; different definitions remain distinct, and the interner does not keep unused specs alive.

ComplexFieldSpec parents remain separate because their equality does not include their children; their ordinary child specs are shared. Shared segment-derived specs are frozen before interning: setters reject mutation, nested time settings and collection properties are detached and protected, and mutable default values (including BYTES and UUID arrays) are defensively copied on access. This prevents a caller from changing another segment or corrupting the interner key. The Schema container remains separate for each segment. Ordinary table schemas remain mutable; callers needing an editable field definition can deserialize FieldSpec.toJsonObject().

Custom defaults preserve their value semantics, including BIG_DECIMAL scale and negative-zero FLOAT/DOUBLE values. On-disk formats, public signatures, and segment-metadata REST JSON remain unchanged.

Tests

  • ColumnMetadataImplTest: shared type-default constants where available, value-equivalent UUID defaults, custom STRING/JSON identity across distinct specs, literal round-trips, date-time metadata, and serialized JSON equivalence. Equal specs share identity; changes to names, types, defaults, the single-value setting, max length, or time format/granularity remain distinct. COMPLEX parents stay separate while children share.

  • SegmentMetadataImplTest: shared names, defaults, and FieldSpecs across loads; separate Schema containers; OPEN_STRUCT parents and children; time-column identity across directory/stream loads and metadata-map keys; and null time columns.

  • FieldSpecFreezeTest: all setter guards, retained aliases, recursive mutable defaults, nested TIME state, Jackson update rejection, and mutable JSON/Java serialization copies. Metadata regressions also cover cross-segment mutation rejection and stable interner identity after attempted mutation.

Local validation at a564ed6de4: 130 focused SPI/metadata tests and 6 actual SQL DISTINCT/null-ordering cases (STRING, BIG_DECIMAL, BYTES) pass with JDK 25. Spotless, Checkstyle, and license checks pass for all three affected modules. An independent review across eight domains has no remaining findings.

Performance rerun at a564ed6

Three-way JMH comparison: base 5771d6acea, previous head e265da96c8, and the immutability fix a564ed6de4. Each timing case has three fresh JVM forks in balanced order, with 3 × 1-second warmup and 5 × 1-second measurement iterations, on JDK 25 with G1 string deduplication enabled. All variants use the same freshly built dependency runtime and exact source overlays.

Measurement Base → final Observation
Reachable metadata graph, INT / mixed 13,654,776 → 8,233,960 B / 15,492,328 → 8,955,992 B 39.70% / 42.19% smaller with G1 dedup ON; final equals pre-fix bytes in these fixtures
200-column parse, ordinary defaults, 1 thread 2.174 → 2.170 ms Essentially unchanged
200-column parse, custom numeric defaults, 1 thread 2.059 → 2.185 ms +6.11% versus base; +1.83% versus pre-fix
GROUP BY, steady / 200 background metadata parses per second 505.8 → 518.4 us / 515.0 → 532.4 us Mean latency +2.49% / +3.37%
SUM, steady / background 346.3 → 333.3 us / 377.4 → 353.2 us Mean latency -3.74% / -6.41%

The eight-thread metadata means are 6–8% higher than base but have substantial fork variation. SQL directions are mixed and fork ranges overlap; these results do not establish a general query speedup or slowdown. SQL uses four native v3 segments, 10,000 rows and 200 INT columns per segment, through real local filter/aggregation/broker reduction code. Independent results are checked before timing and after measurement with the loader active; all background rates are within 1% of 200 parses/s. This excludes network, distributed service, and Parquet/S3 latency.

Frozen getters incur the intended defensive-copy cost: 16-byte BYTES/UUID reads average 2.2–2.3 ns and allocate 32 B, while 1-KiB BYTES averages 26.0 ns and allocates 1,040 B (base/pre-fix getters are approximately 0.46 ns with no allocation). INT and empty BYTES remain allocation-free. These are per-getter measurements; inspected readers cache defaults during setup, and the DISTINCT null-placeholder path reads once per segment rather than per row.

Memory fixtures retain 200 columns × 200 separately parsed segment metadata objects; shared FieldSpecs fall from 40,000 to 200. The graph includes instance fields and backing arrays but excludes static weak-interner/native tables and other server memory, so this is not a net whole-heap result. Deduplication activity is verified in GC logs and all graphs stabilize across three final passes. The scalar/mixed memory fixtures do not measure TIME or mutable collection/binary defaults. All raw fork data, source/dependency hashes, fixture snapshots, commands, and verification results are retained in the local benchmark artifact.

Stack

Based on master. Review this PR against its base for this layer's changes.

Current open chain; #19480 (lazy index-size storage) is already merged, and #19476 is absorbed into #19473.

  1. Share FieldSpecs and repeated values across segment metadata loads #19473 shared FieldSpecs, canonical default-null values, and interned strings
  2. Delegate immutable DataSourceMetadata to ColumnMetadata instead of snapshotting it #19474 delegating immutable DataSourceMetadata
  3. Fold PhysicalColumnIndexContainer's IndexTypeMap into a presence mask and a dense reader array #19475 presence-mask index container
  4. Materialize immutable-segment columns lazily behind an opt-in instance config (default off) #19477 opt-in lazy column materialization
  5. Slim ColumnMetadataImpl to 72 bytes and derive the per-segment Schema lazily #19478 slim ColumnMetadataImpl and lazy per-segment Schema
  6. Store numeric column min/max as primitives instead of boxed Comparables #19479 primitive numeric min/max
  7. Hold segment column metadata in sorted arrays and derive the map on demand #19481 sorted-array column metadata storage
  8. Stop the segment preprocess from building a Schema per segment #19486 segment preprocessing without building a Schema
  9. Prune segments from column metadata instead of materializing them #19511 segment pruning directly from column metadata

@codecov-commenter

codecov-commenter commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.18898% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.74%. Comparing base (5771d6a) to head (a564ed6).

Files with missing lines Patch % Lines
...main/java/org/apache/pinot/spi/data/FieldSpec.java 89.83% 2 Missing and 4 partials ⚠️
.../java/org/apache/pinot/spi/data/TimeFieldSpec.java 50.00% 4 Missing and 2 partials ⚠️
...org/apache/pinot/spi/data/TimeGranularitySpec.java 90.47% 1 Missing and 1 partial ⚠️
...a/org/apache/pinot/spi/data/DateTimeFieldSpec.java 85.71% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19473      +/-   ##
============================================
+ Coverage     67.72%   67.74%   +0.02%     
  Complexity     1450     1450              
============================================
  Files          3490     3490              
  Lines        225032   225147     +115     
  Branches      35527    35552      +25     
============================================
+ Hits         152393   152530     +137     
+ Misses        60612    60585      -27     
- Partials      12027    12032       +5     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 67.74% <88.18%> (+0.02%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 67.74% <88.18%> (+0.02%) ⬆️
unittests 67.74% <88.18%> (+0.02%) ⬆️
unittests1 57.86% <88.18%> (+0.05%) ⬆️
unittests2 39.49% <39.37%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@xiangfu0
xiangfu0 force-pushed the xiangfu0/data-3221-2-intern-parse branch from b61c923 to 987446c Compare September 11, 2026 09:04
Base automatically changed from xiangfu0/data-3221-1-lazy-index-sizes to master September 11, 2026 20:26
@xiangfu0
xiangfu0 force-pushed the xiangfu0/data-3221-2-intern-parse branch from 987446c to e08955f Compare September 11, 2026 20:26
@xiangfu0
xiangfu0 requested review from Jackie-Jiang and yashmayya and a lite review from Copilot September 11, 2026 20:27

Copilot AI 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.

🟡 Changes recommended

One or more issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Canonicalizes default-null values and interns repeated metadata strings to reduce per-segment heap usage.

Changes:

  • Reuses type-default constants and interns custom literals.
  • Interns column, parent, date-time, and complex-child names.
  • Adds identity, JSON, and cross-segment sharing tests.
File summaries
File Description
pinot-segment-spi/src/test/java/org/apache/pinot/segment/spi/index/metadata/ColumnMetadataImplTest.java Updated as part of this pull request.
pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/metadata/SegmentMetadataImpl.java Updated as part of this pull request.
pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/metadata/ColumnMetadataImpl.java Updated as part of this pull request.
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/SegmentMetadataImplTest.java Updated as part of this pull request.
Review details

Suppressed comments (2)

pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/SegmentMetadataImplTest.java:67

  • These static imports are out of the lexicographic order used by the surrounding tests and the repository's Spotless import-order configuration; formatting/checks will reorder them. Move assertSame before assertTrue.
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.assertSame;

pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/metadata/ColumnMetadataImpl.java:575

  • The new non-default path claims to share custom literals via literal.intern(), but the added custom-default tests only use value equality, and the segment-level test covers only type-default constants. Add a focused parse/load pair with distinct String instances for a custom STRING/JSON default and assert identity; otherwise removing this interning would still pass the current suite.
    return dataType.equals(FieldSpec.getDefaultNullValue(fieldType, dataType, literal), typeDefault) ? null
        : literal.intern();
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@Jackie-Jiang Jackie-Jiang 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.

Most segment will have the same FieldSpec, so ideally if we can dedup the FieldSpec, that will provide the biggest saving

@xiangfu0

Copy link
Copy Markdown
Contributor Author

@Jackie-Jiang, whole-FieldSpec deduplication is already implemented in #19476 (part 5 of this stack). ColumnMetadataImpl.extractFieldSpec() uses a weak interner keyed by FieldSpec.equals/hashCode, so equal column definitions share one instance across segments while changed definitions remain distinct. Complex parents are excluded because their equality does not include child definitions; their eligible children are shared. That PR also documents that shared segment-derived specs must be treated as immutable and adds identity, differing-definition, and weak-reference tests.

I kept that change as a separate reviewable layer and added an explicit pointer near the top of this PR description.

@xiangfu0

Copy link
Copy Markdown
Contributor Author

@Jackie-Jiang Yes, sharing the whole FieldSpec also removes the repeated spec object. That is already implemented in follow-up #19476: equal DIMENSION, METRIC, TIME and DATE_TIME specs share one instance through a weak interner, while differing definitions remain separate. It includes tests for repeated segment loads, differing field definitions, and collection after the specs are released.

COMPLEX parents are excluded because their equality does not include children; ordinary child specs are shared. The read-only contract for shared segment-derived specs is documented there.

This PR handles repeated values inside and alongside the specs, including metadata-map keys and the segment time-column name. I updated the description to call out #19476 explicitly and corrected the stale base-branch description: #19473 now targets master.

@xiangfu0 xiangfu0 changed the title Canonicalize the default null value and intern per-column strings at metadata parse time Share FieldSpecs and repeated values across segment metadata loads Sep 13, 2026
@xiangfu0
xiangfu0 removed this pull request from stack #19484 September 13, 2026 01:16
@xiangfu0
xiangfu0 added this pull request to stack #19540 September 13, 2026 01:17
@xiangfu0

Copy link
Copy Markdown
Contributor Author

Folded #19476 into this PR in 862528b so FieldSpec deduplication and value canonicalization can be reviewed together. Equal DIMENSION, METRIC, TIME, and DATE_TIME specs now share a weakly interned instance across loaded segments. COMPLEX parents remain separate because their equality does not include children; their eligible children share normally. The metadata APIs document the read-only contract.

The 48 focused metadata tests pass, including equal/different definitions, shared specs across segment loads, distinct schema containers, custom STRING/JSON literals across distinct specs, and time-column identity. Spotless, Checkstyle, and license checks pass. The dependent stack was restacked to keep each optimization once; #19477 now follows #19475 and #19476 is superseded.

@xiangfu0
xiangfu0 requested review from Jackie-Jiang and a lite review from Copilot September 13, 2026 04:14
xiangfu0 and others added 4 commits September 12, 2026 21:14
…metadata parse time

A server retains one ColumnMetadataImpl + FieldSpec per (segment, column) for as long as the segment is loaded.
For wide segments (1000+ columns, tens of thousands of segments per server) two per-column allocations made
on the metadata.properties parse path add up to ~120 bytes per column that carry no information:

- The segment creator writes `column.<c>.defaultNullValue` for every column, so `extractFieldSpec` handed the
  literal to the FieldSpec constructor, which boxed it (`Integer.valueOf("-2147483648")`) and kept the literal
  in the transient `_stringDefaultNullValue` - a fresh Integer, String and byte[] per column per segment even
  though the value is the type default. `ColumnMetadataImpl.extractFieldSpec` now parses the literal, compares
  it with `FieldSpec.getDefaultNullValue(fieldType, dataType, null)` under `DataType.equals` (the predicate
  `FieldSpec.equals`/`hashCode` use, byte[]-safe) and passes `null` when they match, so the spec holds the
  shared static `FieldSpec.DEFAULT_*` constant and retains no literal. A custom default is kept verbatim (after
  the STRING special-character recovery) and interned, so the segments of a table share it; a combination
  without a type default (a METRIC BOOLEAN with an explicit default) keeps parsing the literal as before.
- Column names recur in every segment of a table, yet each segment parsed its own copy that was then retained
  six times over: the column metadata map key, FieldSpec._name, the Schema map key and dimension/metric list
  entry, and the loader's per-column maps. `SegmentMetadataImpl.addPhysicalColumns` now interns the parsed name,
  and `extractFieldSpec` interns COLUMN_NAME, PARENT_COLUMN, DATETIME_FORMAT, DATETIME_GRANULARITY and complex
  child names, so every String the metadata graph retains is one instance per distinct value per JVM. The JVM
  string table holds interned strings weakly, so they live exactly as long as a loaded segment references them
  and the table is bounded by the number of distinct column names; no code compares column names by identity.

Compatibility: metadata.properties is read and written exactly as before (writers keep emitting
defaultNullValue). The `/tables/{t}/segments/{s}/metadata` payload is unchanged because it bean-serializes
`FieldSpec.getDefaultNullValue()` (a value that is equal by construction) - pinned by a test for every data
type, including BYTES and UUID. FieldSpec.equals/hashCode ignore `_stringDefaultNullValue` and
getDefaultNullValueString() derives from the value, so Schema equality against the table schema, default-column
comparisons and reload decisions are unaffected. The only observable difference is that
`FieldSpec.toJsonObject()` of a segment-derived BYTES column no longer emits a redundant `defaultNullValue: ""`
(it compares the byte[] against the type default by identity, and the spec now holds the constant); no
endpoint serializes a segment-derived Schema that way. No public signature changes; mixed-version safe
(server-local, in-memory only).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@xiangfu0

Copy link
Copy Markdown
Contributor Author

Fixed the shared-mutation correctness issue in a564ed6. Interned specs now reject setters, detach/freeze nested TIME settings and collection aliases, and defensively copy mutable defaults. Ordinary schemas and deserialized FieldSpec.toJsonObject() copies remain editable.

Validation: 130 SPI/metadata tests plus 6 actual SQL null-ordering cases passed; Spotless, Checkstyle and license checks passed. Regression tests cover cross-segment mutation, stable interner keys, BYTES/UUID arrays, Jackson updates and editable copies. Independent source review has no remaining findings.

The fresh three-way benchmark is complete; detailed methodology and numbers are in the PR description. With G1 dedup ON, the reachable metadata graph is 39.7–42.2% smaller than base and unchanged by freezing in these fixtures. One-thread custom-default parsing is 6.1% slower than base (1.8% slower than pre-fix). GROUP BY mean latency is 2.5–3.4% higher, while SUM is 3.7–6.4% lower, with overlapping fork ranges. Nonempty BYTES/UUID getters have a measured allocation cost: 32 B/read for 16-byte defaults, or 1,040 B/read for 1 KiB defaults. This is a bounded local comparison, not a production latency or whole-server heap claim.

Current-head CI is still running; local validation is separate from CI and approval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

memory Related to memory usage or optimization performance Related to performance optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants