Skip to content

perf(ebean-dao): implement config-controlled multi-aspect batch read - #642

Merged
darshilbhayani92 merged 1 commit into
linkedin:masterfrom
darshilbhayani92:dbhayani/multi-aspect-read-routing
Sep 22, 2026
Merged

darshilbhayani92 merged 1 commit into
linkedin:masterfrom
darshilbhayani92:dbhayani/multi-aspect-read-routing

Conversation

@darshilbhayani92

@darshilbhayani92 darshilbhayani92 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements the config-controlled multi-aspect batch read on top of the API surface + routing introduced in the base PR (#641). This collapses N per-aspect SELECTs into a single bundled SELECT of all requested aspect columns per entity table — the read-latency optimization for META-24100.

Stacked on #641 (base branch = dbhayani/multi-aspect-read-primitive). It only makes the MULTI_ASPECT / DUAL paths functional; the default strategy is still PER_ASPECT, so behavior is unchanged unless explicitly opted in.

Review #641 first — this PR's diff is only the implementation delta on top of it.

What's included

File Change
SQLStatementUtils.java createMultiAspectReadSql(...) — build one bundled SELECT of all aspect columns per table; urns escaped via escapeReservedCharInUrn; row-level deleted_ts IS NULL filter; also selects deleted_ts when includeSoftDeleted
EbeanLocalAccess.java batchGetUnionMultiAspect(...) — group keys by entity table, run one query per table, then row-driven mapping back to aspects (see below)
InstrumentedEbeanLocalAccess.java, UsageTrackingEbeanLocalAccess.java implement the stubbed decorator overrides to delegate the new read (metrics instrumentation + usage emission), required now that the routing seam actually invokes it

Mapping semantics (row-driven, matches the per-aspect path)

Rather than reverse-looking-up rows by the requested urn string, the mapping iterates the returned rows (like the per-aspect path) and emits one aspect per requested (urn, aspect) pair:

  • Case-insensitive urn matching — requested pairs are keyed on a lower-cased urn (matchKey), so an aspect is not silently dropped when the stored urn differs from the requested urn by canonical/case. The returned aspect's urn is derived from the row itself, matching per-aspect output.
  • Duplicate keys collapse — requested pairs live in a Set (requestedPairs); requestedPairs.remove(...) both restricts output to requested pairs and deduplicates repeated (urn, aspect) keys, matching the per-aspect Set<Urn> grouping.
  • Skip semantics preserved — missing column → skip (single validator.columnExists check during key collection), no row / null cell → skip.

Soft-delete semantics

A bundled query can't AND per-column JSON soft-delete checks without dropping the whole row when any single aspect is soft-deleted. So soft-delete is handled in two layers:

  • row level in SQL (deleted_ts IS NULL, dropped when includeSoftDeleted), and
  • per-cell during mapping via EBeanDAOUtils.isSoftDeletedAspect.

This preserves parity with the per-aspect path.

Validation in prod without member impact (DUAL)

The DUAL strategy (in #641's newSchemaBatchGet) reads both paths, compares them via EBeanDAOUtils.compareResults (order-insensitive; logs a WARN on mismatch, never throws), and returns the per-aspect (primary) result only. So multi-aspect can be shadow-validated in production with zero member-facing risk before flipping to MULTI_ASPECT. Comparison is by aspect identity (urn, aspect, version), which catches the real read-path risks (missing / extra / duplicate aspects, count and soft-delete-filtering differences).

Tests

The two stub tests from #641 are upgraded to full assertions, plus new coverage for the review feedback:

  • SQLStatementUtilsTest.testCreateMultiAspectReadSql — asserts the exact bundled SQL for includeSoftDeleted false/true, using real production dataset URNs (from the PR test(ebean-dao): baseline benchmark for pre-#622 multi-aspect read [META-24100] #640 read benchmark).
  • EbeanLocalAccessTest.testBatchGetUnionMultiAspectMatchesPerAspect — per-aspect vs multi-aspect result parity, including missing-column and non-existent-urn skip.
  • EbeanLocalAccessTest.testBatchGetUnionMultiAspectExcludesSoftDeletedAspect — soft-delete behavior (requested in review): a soft-deleted aspect is excluded when includeSoftDeleted=false and returned/flagged when true, with per-aspect parity.
  • EbeanLocalAccessTest.testBatchGetUnionMultiAspectDeduplicatesRepeatedKeys — duplicate (urn, aspect) keys collapse to one result, matching the per-aspect path.

All pass locally (only the unrelated pre-existing macOS-only testParseLocalRelationshipField flake fails).

Review feedback addressed

Notes / follow-up

  • URN chunking for very large IN (...) lists is intentionally not included here and can be added in a later change.
  • Multi-aspect filter (list / listUrns) support is a separate follow-up (PR 3), reusing readSqlRowForAspect.
  • Rollout stays gated behind AspectReadStrategy (PER_ASPECT default → DUAL compare → MULTI_ASPECT).

@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch 3 times, most recently from 24cff49 to e2c2fcd Compare September 11, 2026 22:14
@darshilbhayani92
darshilbhayani92 changed the base branch from master to dbhayani/multi-aspect-read-primitive September 11, 2026 22:19
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-primitive branch from 91b8360 to 5910b08 Compare September 11, 2026 22:32
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch from e2c2fcd to d726dfb Compare September 11, 2026 22:32
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-primitive branch from 5910b08 to 16b49ac Compare September 11, 2026 22:37
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch from d726dfb to f74070f Compare September 11, 2026 22:37
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-primitive branch from 16b49ac to 73c2356 Compare September 11, 2026 22:41
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch from f74070f to becabe0 Compare September 11, 2026 22:41
@darshilbhayani92 darshilbhayani92 changed the title perf(ebean-dao): wire config-controlled multi-aspect read routing perf(ebean-dao): implement config-controlled multi-aspect batch read Sep 11, 2026
@Nonnull List<AspectKey<URN, ? extends RecordTemplate>> keys, int keysCount, int position,
boolean includeSoftDeleted, boolean isTestMode) {
throw new UnsupportedOperationException("batchGetUnionMultiAspect is not implemented yet");
final List<EbeanMetadataAspect> result =

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.

Just curious, which of the decorator we are using now? we have instrumental one and the usage tracking ones..

@darshilbhayani92 darshilbhayani92 Sep 18, 2026 •

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.

It's host-configured and the two compose in EbeanLocalDAO — base EbeanLocalAccess, optionally wrapped by InstrumentedEbeanLocalAccess (setBenchmarkMetrics) and/or UsageTrackingEbeanLocalAccess (setUsageEmitter, one-shot, skipped for NoOpDaoUsageEmitter), in either order. So at runtime it can be none / either / both.

The important guarantee is that the new read behaves identically under any of those configs. I verified batchGetUnionMultiAspect has exact parity with batchGetUnion in both decorators:

  • UsageTracking: delegates + emitRead("batchGetUnionMultiAspect", ...) with the same emissionEnabled() && !isTestMode && !DaoReadContext.isInternalRead() gating.
  • Instrumented: instrument("batchGetUnionMultiAspect", ..., bucketCount(keys.size()), ...) — same pattern.

So usage events still fire and benchmark timings are still recorded for the multi-aspect path regardless of which decorator(s) are active — no coverage gap. No code change needed.

final Class<ASPECT> aspectClass = (Class<ASPECT>) aspectKeys.get(index).getAspectClass();
final String tableName = isTestMode ? getTestTableName(entityUrn) : getTableName(entityUrn);
final String columnName = getAspectColumnName(entityUrn.getEntityType(), aspectClass);
if (!validator.columnExists(tableName, columnName)) {

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.

Is this check needed? or we can do it by check the sqlRow directly?

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.

Cleaned up — the previous version called validator.columnExists(...) twice (once when grouping keys, once again in the mapping pass). The rewrite computes it once during key collection; the mapping pass is now purely row-driven and reads columns straight off the SqlRow (skipping null cells via sqlRow.get(columnName) == null), so the redundant second check is gone. (cb39303)

if (!validator.columnExists(tableName, columnName)) {
continue;
}
final SqlRow sqlRow = urnToRow.get(entityUrn.toString());

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.

sqlRow.getString("urn") might not match entityUrn.toString(), as we have canical urn stuff, you might want to make it case insensitive.

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.

check with @mridul111998 for more context on this

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.

Good catch — fixed. The old code did an exact-string urnToRow.get(entityUrn.toString()) lookup, which would silently drop an aspect if the stored urn differed from the requested urn by canonical/case. I reworked the mapping to be row-driven (like the per-aspect path): it now iterates the returned rows and matches each against the requested (urn, aspect) pairs via a case-insensitive key (matchKey lower-cases the urn). The returned aspect's urn is derived from the row itself, so it matches per-aspect output exactly. (cb39303)

@chmeng0 chmeng0 Sep 21, 2026 •

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.

the fix looks clean.
not blocking: might worth noting that it relies on the urn column being case-insensitively collated so WHERE urn IN (...) returns case-variant rows for matchKey - mysql's default utf8mb4 collation is _ci, which is fine, if we ever need to use this with TiDB (default utf8mb4_bin, case sensitive), might need to explicitly pin ci collation

public <ASPECT extends RecordTemplate> List<EbeanMetadataAspect> batchGetUnionMultiAspect(
@Nonnull List<AspectKey<URN, ? extends RecordTemplate>> aspectKeys, int keysCount, int position,
boolean includeSoftDeleted, boolean isTestMode) {
throw new UnsupportedOperationException("batchGetUnionMultiAspect is not implemented yet");

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.

Small edge-case:  If a direct  IEbeanLocalAccess  caller passes duplicate  (urn, aspect)  keys, the multi-aspect path returns duplicates while the per-aspect path returns one result because it groups into  Set. Not an issue for  EbeanLocalDAO.get(...)  if all callers go through the  Set  API, but worth either matching the per-aspect behavior or documenting/testing the expected direct-access behavior.

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.

Fixed as part of the same row-driven rewrite. Requested (urn, aspect) pairs now go into a requestedPairs Set, and the mapping loop uses requestedPairs.remove(...) — which both restricts output to requested pairs and collapses duplicate keys to a single result, matching the per-aspect path's Set<Urn> grouping. Added testBatchGetUnionMultiAspectDeduplicatesRepeatedKeys asserting parity when the same key is passed twice. (cb39303)

@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-primitive branch from 73c2356 to 9e8df27 Compare September 17, 2026 17:48
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch from becabe0 to 8b65bc6 Compare September 17, 2026 17:48
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-primitive branch from 9e8df27 to 9a983cd Compare September 17, 2026 18:06
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch 2 times, most recently from c1cdb88 to cb39303 Compare September 17, 2026 23:31
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-primitive branch from 9a983cd to b01a751 Compare September 18, 2026 17:48
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch from cb39303 to 69f1d8a Compare September 18, 2026 17:49
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-primitive branch from b01a751 to ae85ade Compare September 18, 2026 18:16
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch from 69f1d8a to 3dab7c8 Compare September 18, 2026 18:16
darshilbhayani92 added a commit that referenced this pull request Sep 18, 2026
… [META-24100]

Add MultiAspectReadBenchmarkTest measuring the current per-aspect read
(one SELECT per aspect) against the bundled multi-aspect read from #642
(a single SELECT per entity table) on identical seeded data, and report
the query-count reduction and p50 latency speedup.

Local in-process MariaDB result (73-aspect entity, 30 urns, 200 iters):
current 73 SELECTs p50 27.431ms; multi-aspect 1 SELECT p50 1.193ms =>
73x fewer round-trips, p50 latency 95.7% lower (~23x faster).

Gated behind -Dgma.benchmark=true (skipped in CI). URN batch loaded from
benchmark-urns.txt so it is editable without recompiling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-primitive branch from ae85ade to 5ea7cfa Compare September 18, 2026 18:34
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch from 3dab7c8 to 5549f68 Compare September 18, 2026 18:34
darshilbhayani92 added a commit that referenced this pull request Sep 18, 2026
… [META-24100]

Add MultiAspectReadBenchmarkTest measuring the current per-aspect read
(one SELECT per aspect) against the bundled multi-aspect read from #642
(a single SELECT per entity table) on identical seeded data, and report
the query-count reduction plus p50/p90/p99/max latency speedup.

Local in-process MariaDB result (73-aspect entity, 30 urns, 200 iters):
current 73 SELECTs p50 26.985ms p99 34.415ms; multi-aspect 1 SELECT p50
1.139ms p99 1.375ms => 73x fewer round-trips, p50 ~23.7x faster, p99 ~25x.

Reuses the benchmark harness (urn resource, gma.benchmark gate) added by
the baseline #640; only adds the incremental gma.benchmark.urnFile hook.
Gated behind -Dgma.benchmark=true (skipped in CI).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@darshilbhayani92
darshilbhayani92 changed the base branch from dbhayani/multi-aspect-read-primitive to master September 22, 2026 19:35
Fill in the multi-aspect read behavior on top of the API surface + routing:

- SQLStatementUtils.createMultiAspectReadSql: build one bundled SELECT per
  entity table (row-level deleted_ts filter only)
- EbeanLocalAccess.batchGetUnionMultiAspect: group keys by table, one read
  per table, map each (urn, aspect) key preserving per-aspect semantics
- Instrumented/UsageTracking decorators: implement the stubbed override to
  delegate the new read (instrument + usage emission)
- Update the stubbed surface tests (previously asserting UnsupportedOperation)
  to assert the real SQL builder output (using real production dataset URNs
  from the PR linkedin#640 read benchmark) and multi-aspect read behavior

The AspectReadStrategy routing seam and stubbed surface already exist in the
base PR; this PR only makes the MULTI_ASPECT/DUAL paths functional. Default
strategy is PER_ASPECT, so behavior is unchanged unless opted in.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@darshilbhayani92
darshilbhayani92 force-pushed the dbhayani/multi-aspect-read-routing branch from 5549f68 to f544ad2 Compare September 22, 2026 19:35

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

LGTM

@darshilbhayani92
darshilbhayani92 merged commit a9e8db0 into linkedin:master Sep 22, 2026
2 checks passed
darshilbhayani92 added a commit to darshilbhayani92/datahub-gma that referenced this pull request Sep 23, 2026
… [META-24100]

Add MultiAspectReadBenchmarkTest measuring the current per-aspect read
(one SELECT per aspect) against the bundled multi-aspect read from linkedin#642
(a single SELECT per entity table) on identical seeded data, and report
the query-count reduction plus p50/p90/p99/max latency speedup.

Local in-process MariaDB result (73-aspect entity, 30 urns, 200 iters):
current 73 SELECTs p50 26.985ms p99 34.415ms; multi-aspect 1 SELECT p50
1.139ms p99 1.375ms => 73x fewer round-trips, p50 ~23.7x faster, p99 ~25x.

Reuses the benchmark harness (urn resource, gma.benchmark gate) added by
the baseline linkedin#640; only adds the incremental gma.benchmark.urnFile hook.
Gated behind -Dgma.benchmark=true (skipped in CI).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
darshilbhayani92 added a commit to darshilbhayani92/datahub-gma that referenced this pull request Sep 23, 2026
… [META-24100]

Add MultiAspectReadBenchmarkTest measuring the current per-aspect read
(one SELECT per aspect) against the bundled multi-aspect read from linkedin#642
(a single SELECT per entity table) on identical seeded data, and report
the query-count reduction plus p50/p90/p99/max latency speedup.

Local in-process MariaDB result (73-aspect entity, 30 urns, 200 iters):
current 73 SELECTs p50 26.985ms p99 34.415ms; multi-aspect 1 SELECT p50
1.139ms p99 1.375ms => 73x fewer round-trips, p50 ~23.7x faster, p99 ~25x.

Reuses the benchmark harness (urn resource, gma.benchmark gate) added by
the baseline linkedin#640; only adds the incremental gma.benchmark.urnFile hook.
Gated behind -Dgma.benchmark=true (skipped in CI).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants