Conversation
Adds the URN logical DataType with two storage paths: - Single-entity typed (e.g., urn:li:memberId) — stores compact LONG keys with UrnReconstructingLongDictionary for transparent SELECT. - Multi-entity / untyped — stores full URN strings; multi-entity also writes a companion <col>__urnEntityIdx INT column for fast urnEntity() lookup at query time. Includes ingestion transformers (UrnKeyEncoding, UrnValidation), scalar functions (urnEntity, urnKeyLong + underscore aliases), predicate evaluator support (EQ/IN/RANGE/REGEXP_LIKE), and a JMH benchmark showing 2.1x–7.4x speedups vs untyped STRING storage across four operations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two query-time optimizations that the previous URN commit deferred. Multi-entity companion-column fast path for urnEntity(): - UrnEntityTransformFunction gains a COMPANION_LOOKUP strategy that reads the pre-computed entity index from the companion <col>__urnEntityIdx INT column and indexes into a String[] of entity types. No URN parsing per row. - ProjectPlanNode auto-projects the companion column whenever urnEntity(col) is referenced on a multi-entity URN column. - SegmentColumnarIndexCreator now includes synthetic companion columns in the DIMENSIONS list written to metadata.properties so they are loaded as physical columns when the segment is opened. Filter pushdown for urnKeyLong(typed col) OP literal: - FilterPlanNode rewrites predicates of the form urnKeyLong(typed_long_urn_col) OP literal into col OP entityPrefix:literal, sending them through the existing identifier-based dictionary fast path with prefix stripping inside UrnReconstructingLongDictionary. Supports EQ/NEQ/IN/NOT_IN/RANGE. Multi-entity URN columns are skipped because the literal alone cannot identify which entity prefix to apply. Adds 13 new tests (UrnEntityMultiEntityTest x3, FilterPlanNodeUrnRewriteTest x10). All 119 URN tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lterOperator Extends BenchmarkUrnTransform with end-to-end filter benchmarks that compare typed vs untyped URN columns for "col = literal" and "urnEntity(col) = literal" queries on both single-entity and multi-entity columns, and prints on-disk segment sizes at setup. The first benchmark run showed that "WHERE urnEntity(actorId) = ..." queries on multi-entity typed columns were no faster than untyped — the companion column was only being projected by ProjectPlanNode (used by SELECT), not by ExpressionFilterOperator (used by WHERE on function expressions). Hoisted the companion-projection helper into UrnProjectionUtils so both call sites apply it. Multi-entity urnEntity filter is now ~11x faster on typed vs untyped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-entity typed URN columns previously stored full URN strings in the main dictionary (e.g. "urn:li:corpUser:docchial"). With ~16-20 bytes of repeated prefix per dictionary entry, this dwarfed the actual key bytes. UrnKeyEncodingTransformer now writes the main column as "<entityIdx>:<suffix>" so the dictionary only carries the entity index plus the URN key. UrnReconstructingMultiEntityDictionary wraps the loaded STRING dictionary to reconstruct full URN strings on read and to encode incoming full-URN predicate literals before delegating to the underlying STRING lookup. Benchmark (100K rows, 10K unique URNs, 3 entity types): multi-entity typed before: 472,879 bytes multi-entity typed after: 262,862 bytes (44% reduction) vs untyped: 446,692 bytes (1.70x smaller now) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Untyped URN columns now get the same prefix-stripping treatment as typed single-entity columns, at segment build time, whenever every unique value happens to share a common urn:<ns>:<type>: prefix. SegmentColumnarIndexCreator scans the sorted unique values when building a URN column's dictionary; if a single common prefix is detected, the dictionary stores only the suffixes and the discovered entity type is recorded in column metadata as urnTypes. Row-time indexing strips the same prefix so forward-index dictId lookups still match. UrnReconstructingStringDictionary wraps the loaded STRING dict to reconstruct full URN strings on read and to strip the prefix from incoming full-URN predicate literals. UrnEntityTransformFunction now takes the CONSTANT fast path for *any* single-entity URN column (declared or auto-detected), not just those with LONG/INT storage. This makes urnEntity() effectively free on both declared single-entity columns and on untyped columns where a single prefix was discovered. Benchmark (100K rows, 10K unique URNs): single-entity untyped storage: 376,736 -> 216,812 bytes (-42%) urnEntity() filter on untyped: 14,994 -> 1,543 us/op (~10x faster) urnEntity() transform untyped: 1,050 -> 156 us/op (~7x faster) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defines the storage-level data class and segment-metadata property for the
sorted-LONG UrnDictionary design described in ~/urn_sorted_dict_design.md.
No behavior change yet -- segment build still emits the existing
encoded-STRING multi-entity layout. Commit 2 wires up the new dict
creator; commits 3-5 add the read path, drop the companion column, and
update tests/benchmark.
Adds:
* DimensionFieldSpec.EntityDictRange data class: (entityType, endDictIdExclusive)
* DimensionFieldSpec.parseEntityDictRanges / serializeEntityDictRanges
for the "entityType,end;entityType,end;..." header form
* V1Constants.URN_ENTITY_DICT_RANGES metadata key
* SegmentColumnarIndexCreator writes urn.entityDictRanges when set
* ColumnMetadataImpl reads urn.entityDictRanges at segment load
* 6 unit tests in DimensionFieldSpecUrnTest
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the dictionary reader class for the sorted-LONG UrnDictionary layout
described in ~/urn_sorted_dict_design.md. Wraps a LONG buffer delegate
and uses the per-entity dictId ranges loaded in commit 1.
Lookups:
* indexOf("urn:li:corpUser:100") parses the entity prefix to find the
range, then range-restricted binary searches the LONG key.
* getStringValue(dictId) finds which entity range owns the id and
reconstructs the URN string from delegate.getLongValue.
* getLongValue / getIntValue pass straight through to the delegate
(urnKeyLong-style consumers get the raw key with zero overhead).
* getDictIdsInRange supports same-entity range queries; cross-entity
range queries return empty (semantically undefined).
No integration yet -- DictionaryIndexType still returns the existing
LongDictionary / UrnReconstructingMultiEntityDictionary depending on
layout. Commit 3 wires up the build + load paths.
Unit tests cover: range-restricted indexOf, key-collision disambiguation
across entities, reconstruction across all ranges, same-entity range
queries (inclusive/exclusive), cross-entity / unknown-entity rejection,
batch readStringValues, min/max, and pass-through accessors. 20 new
tests in UrnSortedDictionaryTest (145 URN tests pass total).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed LONG
End-to-end wiring of the sorted-LONG UrnDictionary layout for multi-entity
URN columns whose declared entity types all have numeric value types. The
encoded "<entityIdx>:<suffix>" STRING dictionary is replaced with a LONG
dictionary partitioned by entity range; UrnSortedDictionary handles all
read-time reconstruction and lookup.
Storage / build path:
* DimensionFieldSpec.getEffectiveStoredType -> LONG for multi-entity
URN (was STRING).
* UrnKeyEncodingTransformer multi-entity branch now encodes only the
numeric key into the main column. The companion entity-idx INT
column is still written (parallel-write; commit 4 will drop it).
* UrnMultiEntityPreIndexStatsCollector tracks unique (entityIdx,
longKey) pairs by reading the companion column from the row.
Reports isSorted=false so the sorted-forward-index path isn't
triggered (dict IDs are not monotonic across entity boundaries even
when the LONG values are).
* SegmentPreIndexStatsCollectorImpl dispatches the row to the new
collector via collect(value, row) for multi-entity URN columns.
* UrnSortedDictionaryCreator (extends SegmentDictionaryCreator):
consumes EntityKeyPair[] from the stats collector, writes a LONG
buffer sorted by (entityIdx, longKey), and exposes the per-entity
dict-ID ranges. indexOfSV takes an EntityKeyPair from the row
indexer.
* SegmentColumnarIndexCreator branches to UrnSortedDictionaryCreator
for multi-entity URN columns, plumbs EntityKeyPair through
indexRow, and surfaces the computed ranges on the FieldSpec so the
metadata writer persists them.
Read path:
* DictionaryIndexType.read returns UrnSortedDictionary (commit 2)
when the loaded FieldSpec has entityDictRanges.
* ColumnMetadataImpl.parseEntityDictRanges (commit 1) handles the
PropertiesConfiguration comma-split via getStringArray + rejoin.
Tests / benchmarks:
* UrnReconstructingMultiEntityDictionary still exists but is now
unused (commit 4 will delete it).
* UrnKeyEncodingTransformerTest multi-entity assertions updated to
expect numeric LONG keys on the main column. All 145 URN tests
pass.
Companion column is still auto-created in this commit -- the query
layer still uses COMPANION_LOOKUP for urnEntity(). Commit 4 will drop
the companion column entirely and switch urnEntity() to RANGE_LOOKUP
over the entity ranges.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminates the per-row __urnEntityIdx INT companion column for
multi-entity URN columns. Entity index is now stashed on the row in a
transient field at ingest time (UrnKeyEncodingTransformer), consumed by
the stats collector and forward-index code, and dropped before metadata
write. No new physical column lives in the segment.
SPI:
* DimensionFieldSpec.needsCompanionEntityIdxColumn -> usesSortedUrnDictionary
* companionEntityIdxColumnName / createCompanionEntityIdxFieldSpec /
COMPANION_ENTITY_IDX_SUFFIX -> deleted, replaced by
transientUrnEntityIdxFieldName / URN_TRANSIENT_ENTITY_IDX_PREFIX
Build path:
* UrnKeyEncodingTransformer writes the entity index to
__urnEntityIdxBuild_<col> (transient) instead of a column.
* SegmentColumnarIndexCreator / SegmentIndexCreationDriverImpl /
SegmentPreIndexStatsCollectorImpl no longer auto-create, register,
or write the companion column. _companionFieldSpecs map removed,
DIMENSIONS list no longer gets the synthetic suffix appended.
* UrnMultiEntityPreIndexStatsCollector reads the transient row field
instead of a sibling column.
Query path:
* UrnEntityTransformFunction: COMPANION_LOOKUP strategy replaced with
RANGE_LOOKUP. It reads the main column's dict IDs and finds the
owning entity range with a linear scan over the metadata range
table (typically <=16 entries). No per-row companion read, no URN
string parsing.
* UrnProjectionUtils deleted -- nothing to inject into projections.
Callers in ProjectPlanNode and ExpressionFilterOperator updated.
* DictionaryIndexType: branch returning UrnReconstructingMultiEntityDictionary
removed (obsoleted by UrnSortedDictionary returned via the LONG path).
* UrnReconstructingMultiEntityDictionary file deleted.
Tests:
* UrnEntityMultiEntityTest: companionColumnIsPresent ->
entityDictRangesPersistedInMetadata; urnEntityUsesCompanionFastPath
-> urnEntityUsesRangeLookupFastPath; the
urnEntityFallsBackToParseStringWhenCompanionNotProjected case is
obsolete (no companion column to omit) and was dropped.
* UrnKeyEncodingTransformerTest multi-entity asserts updated to read
the transient field instead of a companion column.
* All 144 URN tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backwards-compatibility tests confirming that segments built with
different urnTypes lists (extended set, reordered set) both load and
query correctly. Each segment uses the urnTypes ordering it was built
with -- existing segments do not need to be rewritten when a column's
urnTypes list is extended in the table schema.
Tests:
* multiEntitySegmentsWithDifferentUrnTypesBothQueryable: a v1 segment
with [memberId, groupId] and a v2 segment with [memberId, groupId,
applicationName] both load; v1 metadata keeps its 2-entry list and
queries for the new applicationName entity miss (zero rows have it,
semantically correct), v2 queries for all three.
* multiEntitySegmentsWithReorderedUrnTypesReconstructCorrectly: a v1
segment with [memberId, groupId] and a v2 segment with [groupId,
memberId] both reconstruct the same URN strings on read -- per-
entity ranges in segment metadata carry the entity type, not just
its ordinal.
146 URN tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a v3-layout index_map parser so the benchmark prints the dictionary file size for each segment alongside the total. Total segment size is dominated by the forward index (~70% of bytes), so the dict-only number is what actually shows the URN feature's storage win. Numbers from the run (100K rows, 10K unique keys): multi-entity untyped (STRING dict): 269,981 bytes multi-entity typed (sorted LONG): 80,000 bytes (3.37x smaller) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces UrnSortedDictionary with UrnPolymorphicDictionary: a list of
per-entity sub-dictionaries with cumulative dict-ID offsets. Each
sub-dict is a standard Pinot Dictionary (LongDictionary, StringDictionary,
IntDictionary, ...). The wrapper routes every operation -- indexOf,
getStringValue, getLongValue, getDictIdsInRange -- to the appropriate
sub-dictionary and re-attaches the entity prefix on read.
This is the architecturally-cleaner restatement of the same design: the
current all-numeric-keys case still produces one composite LONG buffer
sliced into per-entity LongDictionary views, with the existing
urn.entityDictRanges metadata recording the slice boundaries. Wire
format is unchanged; this commit is a reader-side refactor.
The next commits extend this to genuinely polymorphic sub-dictionaries
(mixed LONG / STRING / INT per entity), wire up the build path to
choose the natural delegate type per entity, add multi-prefix
auto-detect for untyped URN columns, and introduce a fail/skip/pause
ingest mode for non-URN-shaped data so PARSE_STRING goes away entirely.
* New: UrnPolymorphicDictionary + 19 unit tests (mixed LONG + STRING
sub-dicts exercise the polymorphic routing).
* Removed: UrnSortedDictionary and its 20 tests (superseded).
* DictionaryIndexType.read constructs per-entity LongDictionary views
by slicing the main LONG buffer at the entity-range offsets, then
composes them into a UrnPolymorphicDictionary.
129 URN tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends segment-build URN auto-detection from single-prefix-only to
multi-prefix. If every value in an untyped URN column parses as a
simple URN with a numeric key and at least two distinct entity
prefixes appear in the data, the column is promoted to multi-entity
URN for the rest of the build: urnTypes are synthesized in observed
order, the sorted-LONG UrnDictionary layout is used, and the segment
loads as if the user had declared the column with these urnTypes.
Build-path changes:
* maybeStripUrnPrefix tries single-prefix first; on failure, runs
tryMultiPrefixUrnDetection. When successful, returns EntityKeyPair[]
so UrnSortedDictionaryCreator handles the rest.
* SegmentColumnarIndexCreator picks the dict creator after
preprocessing so EntityKeyPair[] from auto-detect routes through
UrnSortedDictionaryCreator just like declared multi-entity.
* indexRow handles auto-detected multi-entity columns by parsing the
URN string inline (no transformer ran on the untyped column).
* resolveFieldSpec caches synthetic FieldSpecs so per-build mutations
(setEntityDictRanges) persist across resolve calls.
* Skip min/max metadata for multi-entity URN columns: cross-entity
comparisons aren't semantically meaningful and the values won't
round-trip through LONG parsing at load.
Test updates:
* UrnColumnSegmentTest.untypedUrnHasNoUrnTypesInMetadata renamed to
untypedUrnAutoDetectsMultiEntityFromIngestedData; asserts that
multi-prefix all-numeric data triggers auto-detection.
* New untypedUrnAutoDetectedSegmentIsQueryable verifies the segment
round-trips: all four ingested URNs are indexable, getStringValue
reconstructs correctly, and unknown lookups return NULL_VALUE_INDEX.
148 URN tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UrnValidationTransformer now rejects any value that is not a simple URN in untyped URN columns, mirroring the existing typed-column check. With shape guaranteed at ingest time, every URN column reaches query time with structured metadata, so urnEntity() never needs to parse strings per row. UrnEntityTransformFunction's PARSE_STRING strategy is replaced with DICT_LOOKUP for multi-entity STRING-stored columns (mixed-type entities): init walks the dictionary once to build a dictId -> entityPrefix array, then per row reads the dict ID and indexes into the array. The CONSTANT and RANGE_LOOKUP strategies are unchanged. Rejection behaves like JSON ingest: throw when continueOnError=false, or null the value and mark the row INCOMPLETE when continueOnError=true.
…ader + creator) Extends multi-entity URN dictionary storage so each entity can use its own typed sub-dictionary instead of a single uniform LONG buffer. Wire format adds two fields per EntityDictRange: - dictClass: INT/LONG/STRING/...; selects the sub-dictionary reader - numBytesPerValue: fixed-width entry size in the entity's sub-buffer Metadata serialization stays backwards compatible. Ranges that are all default LONG/8-byte still emit the legacy two-field "entityType,end" form; ranges with mixed sub-dict types emit the four-field "entityType,end,dictClass,numBytesPerValue" form. The parser accepts both. DictionaryIndexType.read() hoists multi-entity URN columns above the effective-stored-type switch and slices the file into per-entity byte regions, wrapping each region in its declared sub-dict reader (LongDictionary, StringDictionary, ...). Per-region byte ranges are derived cumulatively from numBytesPerValue. Existing all-LONG segments still load through the LongDictionary path with identical byte layouts. UrnPolymorphicDictionaryCreator writes the polymorphic file: per-entity typed sub-dictionaries concatenated back-to-back. When every entity is LONG/8-byte the file is byte-identical to the prior sorted-LONG layout, so swapping it in for UrnSortedDictionaryCreator is wire-compatible. EntityValuePair generalizes EntityKeyPair to any Comparable value so mixed-type rows can flow through the same sort/index pipeline. Not yet wired into SegmentColumnarIndexCreator -- that swap (and the stats-collector generalization needed to feed mixed-type values into the new creator) is the next step.
…Creator Transparent swap -- the polymorphic creator writes byte-identical output to the legacy sorted-LONG creator when every entity is LONG/8-byte, so all 121 existing URN tests pass without modification. This unifies the multi-entity URN build path around one creator. Mixing in non-numeric entities (next step) just requires generalizing the stats collector and per-row encoder to feed EntityValuePair[] instead of EntityKeyPair[]; the creator side is already ready.
End-to-end wire-up for the polymorphic dictionary layout. Mixed-type multi-entity URN columns (e.g. LONG memberId + STRING corpuser) now write per-entity typed sub-dictionaries instead of falling back to plain STRING storage. DimensionFieldSpec gains usesPolymorphicUrnDictionary(): true for any multi-entity URN with declared LONG/INT/STRING valueTypes (superset of the existing usesSortedUrnDictionary which stays as the narrower all-numeric predicate). Wiring uses the broader predicate everywhere except where the all-numeric fast path is actually load-bearing. UrnKeyEncodingTransformer encodes per-entity-typed values for polymorphic columns: Long for LONG entities, Integer for INT, String for STRING. The column value type is heterogeneous across rows, but the transient entity index keeps the type paired with its entity so downstream readers stay type-safe via EntityValuePair. UrnMultiEntityPreIndexStatsCollector decoupled from LongColumnPreIndexStats and rebuilt around EntityValuePair so mixed-type values flow through the same dedup/sort pipeline as the all-LONG case. SegmentColumnarIndexCreator.indexRow builds EntityValuePair (not EntityKeyPair) for polymorphic columns, with the value pulled directly from the row's typed column value. UrnEntityDictLookupTest now verifies the mixed-type column persists typed EntityDictRanges (LONG memberId + STRING corpuser) and that urnEntity() returns the right prefix per row via RANGE_LOOKUP. DICT_LOOKUP becomes unreachable for new segments since every URN column now has structured per-entity metadata.
Drops the size >= 2 restriction on usesPolymorphicUrnDictionary(): a single-entity URN column becomes a polymorphic dictionary with one EntityDictRange entry. The standalone UrnReconstructingLongDictionary and UrnReconstructingStringDictionary wrappers no longer need to fire for declared single-entity columns -- the polymorphic wrapper already prepends the entity prefix on read, and the metadata overhead is one extra range row. To make this work end to end: - UrnPolymorphicDictionaryCreator handles empty input (a column whose only rows are null) by emitting zero-width per-entity ranges. - UrnMultiEntityPreIndexStatsCollector associates the column's default null sentinel with entity 0 so it gets a dict ID and the polymorphic forward index can resolve null rows. SegmentColumnarIndexCreator.indexRow mirrors that, wrapping the bare typed value as EntityValuePair(0, ...) when the transient entity index is absent. - UrnPolymorphicDictionary.isSorted() reports true for single-entity columns -- one sub-dict, prefix prepended on read, ordering preserved -- so the sorted range-predicate evaluator runs. For multi-entity it stays false (cross-entity isn't lex-sorted as URN strings). - UrnPolymorphicDictionary.getDictIdsInRange computes range bounds via the sub-dict's insertionIndexOf instead of delegating to the sub-dict's getDictIdsInRange (which inherits the base "should not be called" implementation from BaseImmutableDictionary). The result is the half-open [from, to) relative dict-ID range, shifted by the entity's offset.
…ults to STRING All URN columns with dictionaries now use the polymorphic layout. Two unifications land here: 1. DimensionFieldSpec.setUrnTypes defaults any UrnType with no declared valueType to STRING. The polymorphic layout needs a concrete sub-dictionary type per entity; STRING accepts any key shape and is the safe default. Schemas that explicitly set valueType=LONG/INT/STRING are unchanged. 2. Untyped URN columns (no schema-declared urnTypes) flow through a single auto-detect path. tryUrnAutoDetection parses every value as a URN, groups by entity prefix, and chooses a per-entity sub-dict type: LONG when every key for that entity parses as long, otherwise STRING. It then emits an EntityValuePair[] for the polymorphic creator and stashes the discovered urnTypes (with their per-entity valueType) so resolveFieldSpec surfaces a synthetic polymorphic FieldSpec at metadata-write time. The old single-prefix-strip path is gone: _discoveredUrnEntities, maybeStripUrnPrefixForIndex, discoverCommonUrnEntity, and extractEntityPrefix are all removed. Single-prefix columns now run through the same polymorphic codepath as multi-prefix.
With every URN column now flowing through the polymorphic dictionary, the legacy single-entity reconstructing wrappers are dead code. The polymorphic dictionary's getStringValue() already prepends the entity prefix on read and its indexOf()/insertionIndexOf() already accept full URN strings, so wrapping a plain LongDictionary or StringDictionary is no longer needed. DictionaryIndexType.read drops both special-case branches and the wrapper imports; the LONG and STRING cases become plain scalar loads again. Single-entity URN columns either populate entityDictRanges and take the polymorphic load path at the top of read(), or fall through to the scalar load and behave like any other column.
- untypedMixedKeyTypesDiscoverPerEntityValueType: untyped column with mixed LONG (memberId) + STRING (corpuser) keys auto-detects per-entity sub-dict types and round-trips full URN strings through the polymorphic dictionary. - untypedSinglePrefixDiscoveredAsPolymorphic: single-prefix untyped column becomes a 1-entity polymorphic column with LONG sub-dict; no more special-case prefix-stripping path. - declaredUrnTypesWithoutValueTypeDefaultToString: setUrnTypes normalizes any UrnType missing a valueType to STRING.
The all-LONG byte-identical wire compat was a refactor safety net while swapping UrnSortedDictionaryCreator -> UrnPolymorphicDictionaryCreator; it's not needed in production because the URN feature itself is new in this PR, so no existing segments use the legacy form. Collapsing to one format: - serializeEntityDictRanges always emits the 4-field "entityType,end,dictClass,numBytesPerValue" form. - parseEntityDictRanges only accepts the 4-field form; 2-field input is rejected as malformed. - EntityDictRange drops the 2-arg legacy constructor; all callers now pass dictClass and numBytesPerValue explicitly. Segments are now self-describing -- the sub-dict type for each entity is stated in the metadata rather than inferred from defaults.
… file 1. usesSortedUrnDictionary() lost its size > 1 guard, causing FilterPlanNode to skip the urnKeyLong-pushdown rewrite for single-entity LONG URN columns. Restored the guard; the FilterPlanNodeUrnRewriteTest single-entity cases pass again. 2. Mixed-type polymorphic columns + null URN row caused a build-time ClassCastException because NullValueTransformer filled the column with the URN sentinel String, but the polymorphic creator's LONG/INT branch unconditionally cast the value to Number. Made DimensionFieldSpec.getDefaultNullValue() return the entity-0-typed default for polymorphic columns, so the sentinel matches entity 0's sub-dictionary type. 3. UrnPolymorphicDictionary.indexOf / insertionIndexOf / getDictIdsInRange would propagate NumberFormatException out of the query path when a range bound's suffix was non-numeric on a LONG sub-dict (e.g. WHERE col BETWEEN 'urn:li:memberId:abc' AND 'urn:li:memberId:zzz'). Wrapped sub-dict calls in safeIndexOf / safeInsertionIndexOf helpers that treat parse failure as "no such value" (NULL_VALUE_INDEX / ~length). 4. tryUrnAutoDetection promoted the URN null sentinel (DEFAULT_DIMENSION_NULL_VALUE_OF_URN = "urn:pinot:null:0") to a phantom "urn:pinot:null" entity in every untyped URN column with null rows. Filter the sentinel before entity discovery and reserve a type-appropriate null placeholder in entity 0's sub-dictionary so per-row indexer can still resolve null rows. indexRow's auto-detect branch maps sentinels to the same placeholder. Plus: removed .claude/scheduled_tasks.lock from the index and added .claude/ to .gitignore. The lockfile was an accidentally-committed Claude Code runtime artifact with no license header, causing apache-rat:check to fail with "Too many files with unapproved license". RAT now reports Unapproved: 0. Tests: 4 new regression tests, all 112 URN tests pass.
|
@dinoocch - I can spend time reviewing this today if this is somewhat ready / in shape. Let me know if not yet. |
| */ | ||
| @JsonIgnore | ||
| @Nullable | ||
| public List<EntityDictRange> getEntityDictRanges() { |
There was a problem hiding this comment.
Assuming DictRange here is referring to segment dictionary info -- I don't think this information should be kept here. Dictionary is a property of segment storage format. Abstraction wise, schema / fieldSpec should not be aware of segment storage internals / state.
This belongs in ColumnMetadata or any of it's equivalents that live closer to segment layout. May be create a new UrnSegmentLayout.
| * the sorted-LONG dictionary, then dropped — it is not part of the segment schema and is | ||
| * never persisted as a column. | ||
| */ | ||
| public static final String URN_TRANSIENT_ENTITY_IDX_PREFIX = "__urnEntityIdxBuild_"; |
There was a problem hiding this comment.
Similar feedback here (as I gave earlier - https://github.com/linkedin/pinot/pull/206/changes#r3343763062).
This should not be in SPI
It should ideally be in pinot-segment-local (and may be exposed via a helper if needed)
There was a problem hiding this comment.
lol yeah lots to clean up
| * </pre> | ||
| */ | ||
| @Nullable | ||
| private List<UrnType> _urnTypes; |
There was a problem hiding this comment.
Clarifying question - Is it possible to declare an URN without urnTypes ?
| * standard dictionary encoding. Correct but least efficient.</li> | ||
| * </ul> | ||
| * | ||
| * Example schema snippet: |
There was a problem hiding this comment.
Do we support nested / composite URN ?
Something like "urn:li:dataset: (<another urn)"
How will the urnType be defined in this case? The valueType for the entityType will be URN ? I did not see an example for this in the PR description and yet to see in the code (so far during the review).
There was a problem hiding this comment.
Upon reading more code, I am somewhat more confused about how we are handling these. Suggest clearly calling out our stance on this in the PR description.
- The
UrnKeyEncodingTransformerseems to be silently NULLing out composite URN row if it detects URN is not simple. - The
UrnValidationTransformeris explicitly rejecting composite URNs by enforcingurn.isSimple() - The
UrnValidationTransformeris potentially accepting them. It checks only entrityPrefix membership and that will not catchurn:li:dataset( < another urn>)
There was a problem hiding this comment.
Oops sorry this is not done! It's just a draft that I pointed claude at and steered it away from stupid ideas...
So there's probably a lot of kinda weird things - I wanted to start with a proof of concept to iterate on the savings a little and then invest in some more polish, but don't have much (any) dedicated time on this :)
I figure simple urns are a huge proportion of our current usage. This pr will handle the complex ones by treating the remaining part as a string
There was a problem hiding this comment.
No problem. I happen to read the PR for some time and left feedback as otherwise I would have forgotten.
| * overlap that motivates the sorted-LONG layout; and</li> | ||
| * <li>every entity's {@code valueType} is {@code LONG} or {@code INT}.</li> | ||
| * </ul> | ||
| * Callers that need only "is this column polymorphic" (entity-idx-stashing, polymorphic creator |
There was a problem hiding this comment.
What are we referring to as "callers" here ?
Who is making a decision on how the URN is being laid out on disk ?
|
Partial Review - At a high level, I think |
| return _totalLength == 0 ? null : getStringValue(0); | ||
| } | ||
|
|
||
| @Override |
There was a problem hiding this comment.
Is it not possible to sort in lex order at the time of construction ? The current implementation will give incorrect min and max.
I see that for immutable segments, we are simply skipping the min and max for multi-entity URNs.
However, mutable segments cache min and max as rows arrive IIRC. Similarly during segment commit, we may copy wrong min and max ?
|
Leaving some more feedback / questions before I context switch out of this PR review for some time. Will review again when you find time to iterate on it.
Example -- works via FilterPlanNode rewrite to Alternative - Planner sees memberId is a single-entity LONG-typed URN, auto-prepends the prefix.
|
Adds a first-class
URNlogical data type for LinkedIn-style URN columns (urn:<namespace>:<type>:<key>), with compact per-entity-typed storage, polymorphic multi-entity dictionaries, and predicate / transform support tuned for the URN representation.End-to-end JMH benchmarks show 6.9× to 11× speedups vs the equivalent STRING baseline on the queries that matter, and 32–42% smaller on-disk storage.
What this gives you
DataType.URNyou can declare on dimension columns.urnTypesschema annotation that declares which entity-type prefixes are valid (e.g.urn:li:memberId) and the physical key type (LONG/INT/STRING). UnspecifiedvalueTypedefaults toSTRING.URNwith nournTypes) auto-detect entity types and per-entity value types at segment build —LONGwhen every key for an entity parses as a long, otherwiseSTRING.urnEntity(col)andurnKeyLong(col)(plus underscore aliases).UrnValidationTransformer,UrnKeyEncodingTransformer.=,!=,IN,NOT IN, range,REGEXP_LIKE.WHERE urnKeyLong(col) OP Nrewrites to a direct LONG predicate on the underlying typed column.Polymorphic dictionary (the only URN dictionary)
Every URN column with a dictionary uses
UrnPolymorphicDictionary— a list of per-entity sub-dictionaries with cumulative global dict-ID offsets. Each sub-dictionary is a standard Pinot scalar dictionary chosen per entity from its declaredvalueType.The dictionary file on disk is the per-entity sub-buffers concatenated back-to-back. Per-entity ranges +
dictClass+numBytesPerValuelive in column metadata asurn.entityDictRanges, formaturn:li:memberId,N0,LONG,8;urn:li:corpUser,N1,STRING,16;.... The reader slices the file at the byte boundaries derived fromnumBytesPerValueand wraps each region in the matching sub-dictionary class.UrnPolymorphicDictionaryitself is a thin router:getStringValue(globalId)→ linear scan over the small per-entity offset table, compute the local ID, ask the sub-dict for its value, prepend the entity prefix.indexOf(urnString)→ parse the entity prefix, call the matching sub-dict'sindexOf(suffix), add the entity's global offset.getDictIdsInRange(lo, hi)→ same-entity ranges binary-search the sub-dict viainsertionIndexOf; cross-entity ranges return empty (semantically undefined).isSorted()returnstruefor single-entity columns (the one sub-dict is sorted, prefix is constant) andfalsefor multi-entity (cross-entity dict-IDs are not lex-sorted in URN string space).Single-entity URN columns are just polymorphic columns with one entry — no special-case reader, no historical
UrnReconstructing*Dictionarywrappers. The prefix is prepended on read by the polymorphic wrapper itself.Schema configuration
Single-entity typed (fastest path, smallest storage)
{ "dimensionFieldSpecs": [ { "name": "memberId", "dataType": "URN", "urnTypes": [{ "entityType": "urn:li:memberId", "valueType": "LONG" }] } ] }LongDictionarysub-dict, keys only — theurn:li:memberId:prefix is stripped from every entry.Multi-entity typed (numeric — original sorted-LONG case)
{ "name": "actorId", "dataType": "URN", "urnTypes": [ { "entityType": "urn:li:corpUser", "valueType": "LONG" }, { "entityType": "urn:li:corpGroup", "valueType": "LONG" }, { "entityType": "urn:li:applicationName", "valueType": "LONG" } ] }LongDictionarysub-dicts concatenated; per-entity ranges in metadata.WHERE col = 'urn:li:corpUser:100'parses the prefix, routes to thecorpUsersub-dict, does a binary search for100, returns a single absolute dict ID.Multi-entity typed (mixed-type)
{ "name": "subject", "dataType": "URN", "urnTypes": [ { "entityType": "urn:li:memberId", "valueType": "LONG" }, { "entityType": "urn:li:corpuser", "valueType": "STRING" } ] }LongDictionaryregion + oneStringDictionaryregion in the same dictionary file.Untyped (auto-detected)
{ "name": "rawUrn", "dataType": "URN" }LONGwhen every key for that entity parses as a long, otherwiseSTRING.urnTypesare persisted in segment metadata so the column behaves like a declared polymorphic column at load and query time.UrnValidationTransformerrejects non-URN values at ingest (configurablefail/skipviacontinueOnError, mirroring JSON ingest).valueTypedefaults to STRINGIf a declared
UrnTypeomitsvalueType,setUrnTypesnormalizes it toSTRING. Every URN entity ends up with a concrete sub-dictionary type —STRINGis the safe default that accepts any key shape.Query usage
Transform functions
urnEntity(col)→ returns the entity prefix string (e.g."urn:li:memberId"). Three strategies picked atinit(); none parse URN strings per row:Arrays.fillonce per block.dictId → entityPrefixarray atinittime; O(1) per row.urnKeyLong(col)→ returns the numeric key aslong. Direct dictionary read on typed LONG entities.Filtering
EQ / IN / range queries on full URN literals route through standard dictionary-based predicate evaluators — the polymorphic wrapper translates between full URN and per-entity-typed sub-dict lookups transparently.
Performance comparison
Dictionary storage (per entry)
STRING(full URN as string)URN(LONG)URN(INT)URN(STRING)(key suffix only)The savings come from stripping the constant
urn:li:memberId:prefix once into segment metadata instead of repeating it in every dictionary entry. The forward index is identical regardless — it stores dict IDs, not raw values.For real LinkedIn-scale workloads the gap widens with key length:
Lookup latency
col = 'urn:li:memberId:42'42→ binary search 8-byte longscol BETWEEN x AND yurnEntity(col)DICT_LOOKUPlegacy fallback)Arrays.fillwith the constant prefixurnKeyLong(col)Long.parseLongLongDictionary.getLongValue(dictId)Long.parseLong(suffix)The LONG/INT path wins biggest on
urnKeyLongand on range/equality predicates — primitive comparisons vs UTF-8 string comparisons over longer payloads.urnEntity()is constant-time on any single-entity URN column because the prefix is in metadata.Multi-entity URN configurations
LongDictionary/IntDictionaryregions; per-entity dict-ID ranges in metadataLongDictionaryregion back-to-back withStringDictionaryregion in one dictionary file; per-entity ranges select the sub-dicturnTypes)LONGwhen all keys numeric, elseSTRING; same wire format as declaredIngest cost
UrnKeyEncodingTransformerparses each URN row once. For polymorphic columns it replaces the URN string with the per-entity-typed key (Long/Integer/String) and stashes the entity index in a transient row field. The stats collector dedupes on(entityIdx, value)instead of full URN strings — smaller hash keys, faster comparison.Net summary
For numeric-keyed URN columns (the common LinkedIn case): 3–6× dictionary storage win plus measurably faster predicates and
urnKey*()transforms because everything is primitive-typed end to end. For STRING-keyed entities the storage savings are smaller (just the prefix bytes), buturnEntity()becomes O(1) instead of per-row URN parsing.JMH benchmark results
pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkUrnTransform.java— 100K rows, 10K unique keys. Single-entity usesurn:li:memberId. Multi-entity rotates amongurn:li:corpUser,urn:li:corpGroup,urn:li:applicationName.Storage (bytes on disk)
Total segment size:
Dictionary file alone:
So a multi-entity URN column with 1M unique URNs at LinkedIn scale: ~30 MB STRING dict → ~8 MB LONG dict, a ~22 MB-per-segment saving on the dict alone.
End-to-end filter (full FilterPlanNode → execution, µs/op)
WHERE col = 'urn:...'WHERE urnEntity(col) = 'urn:...'Microbenchmarks (single-entity, µs/op)
urnEntity()transformurnKeyLong()transformDictionary.indexOf(String)Dictionary.getStringValue(dictId)Simple EQ filter is essentially a wash because forward-index scan dominates the per-query cost. The dict-lookup speedup is visible in microbenchmarks and in IN-with-many-values queries.
Implementation notes
SPI (
pinot-spi)DataType.URN— logical URN type.DimensionFieldSpec.urnTypes,setUrnTypes(normalizes unspecifiedvalueTypetoSTRING).usesPolymorphicUrnDictionary()(any URN with declared entities + valueTypes),usesSortedUrnDictionary()(all-numeric subset),hasTypedUrnStorage()(numeric stored type),isSingleUrnEntityType(),transientUrnEntityIdxFieldName().DimensionFieldSpec.EntityDictRange— data class carryingentityType,endDictIdExclusive,dictClass,numBytesPerValue.parseEntityDictRanges/serializeEntityDictRanges— 4-field-per-range wire format.Urn.tryParse/Urn.parseutility.Build (
pinot-segment-local)UrnValidationTransformer— rejects non-URN-shaped data at ingest (FAIL by default, SKIP whencontinueOnError=true, mirroring JSON ingest mode). Covers both declared-prefix and untyped columns.UrnKeyEncodingTransformer— for polymorphic columns: parses the URN, looks up the entity index, replaces the column value with the per-entity-typed key (Long/Integer/String), stashes the entity index in a transient row field.UrnMultiEntityPreIndexStatsCollector— dedupes(entityIdx, value)pairs viaEntityValuePair.UrnPolymorphicDictionaryCreator— writes per-entity typed sub-dictionaries concatenated into one file; computes per-entity dict-ID ranges + byte ranges; handles empty input (null-only columns) gracefully.SegmentColumnarIndexCreator— routes any URN column to the polymorphic creator;tryUrnAutoDetectionsynthesizes polymorphicurnTypesfor untyped columns (per-entity LONG when all keys numeric, else STRING).Read (
pinot-segment-local,pinot-segment-spi,pinot-core)UrnPolymorphicDictionary— the only URN dictionary. RoutesindexOf,getStringValue,getDictIdsInRange, etc. to the matching per-entity sub-dictionary.DictionaryIndexType.read— whenentityDictRangesis set, slices the file at per-entity byte offsets and dispatches each region to the declared sub-dict class (LongDictionary,IntDictionary,StringDictionary, ...).ColumnMetadataImpl.fromPropertiesConfigurationreadsurnTypesandurn.entityDictRanges(the latter usinggetStringArray+ rejoin to survivePropertiesConfiguration's comma split).PredicateUtils,RangePredicateEvaluatorFactory,RegexpLikePredicateEvaluatorFactory, EQ/IN/NEQ/NIN) —case URN:routes URN columns through the STRING path; the polymorphic wrapper handles prefix/range translation.UrnEntityTransformFunction—CONSTANT/RANGE_LOOKUP/DICT_LOOKUPstrategies; never parses URN strings per row.UrnKeyLongTransformFunction— direct LONG read on typed columns.TransformFunctionFactory— registersurnEntity,urnKeyLong, and underscore aliases.FilterPlanNode— rewritesurnKeyLong(typed_col) OP literalinto a direct LONG-typed predicate on the underlying column.Common (
pinot-common)UrnFunctions— scalar UDF helpers.case URN:additions in switch statements throughout serdes / encoders so URN flows through the STRING path.Test coverage
98 URN-specific tests pass across
pinot-spi,pinot-segment-local,pinot-core. Suites:DimensionFieldSpecUrnTest,UrnTest,FieldSpecTestpinot-spiUrnFunctionsTestpinot-commonUrnColumnSegmentTest,UrnPolymorphicDictionaryTest,UrnPolymorphicDictionaryCreatorTest,UrnKeyEncodingTransformerTest,UrnValidationTransformerTest,DictionaryIndexTypeTestpinot-segment-localUrnTransformFunctionTest,UrnEntityMultiEntityTest,UrnEntityDictLookupTest,FilterPlanNodeUrnRewriteTestpinot-coreRun locally with:
Compatibility
urnTypesto a column is a forward-only schema change; existing segments aren't rewritten, but new segments will use the compact polymorphic storage.urnTypeslist, per-entity dict-ID ranges, and per-entitydictClass/numBytesPerValueare persisted in segment metadata and read back at load time.Adding entity types over time
When you update a table's schema to add a new entity type to a URN column, existing segments are not retroactively rewritten — they keep using the
urnTypesordering and per-entity types they were built with, and continue to query correctly for the entity types they know about. Newly ingested segments pick up the extended schema. Lookups against an old segment for an entity type it doesn't know about return zero matches, which is the semantically correct answer (the segment contains no rows with that entity). Validated byUrnColumnSegmentTest.multiEntitySegmentsWithDifferentUrnTypesBothQueryableand...ReorderedUrnTypesReconstructCorrectly.Layout history
Earlier drafts of this PR used: a STRING-encoded dictionary plus a per-row
__urnEntityIdxcompanion column; then a sorted-LONGUrnSortedDictionary; then a polymorphic wrapper over typed sub-dicts. The current implementation is the polymorphic wrapper as the only URN dictionary path, with no companion column and no legacy wire format. Segments built with earlier drafts of this PR need re-ingestion. First-time ingestion against any released version of the URN feature uses the final layout directly.