Skip to content

fix(engine) #5589: verify the partition properties before pruning an index lookup to one bucket - #5591

Merged
lvca merged 2 commits into
mainfrom
issue-5589-partitioned-index
Jul 30, 2026
Merged

fix(engine) #5589: verify the partition properties before pruning an index lookup to one bucket#5591
lvca merged 2 commits into
mainfrom
issue-5589-partitioned-index

Conversation

@lvca

@lvca lvca commented Jul 30, 2026

Copy link
Copy Markdown
Member

Fixes #5589.

A type using partitioned(...) bucket selection places each record in the bucket its partition key hashes to, but TypeIndex.getIndexesByKeys pruned every lookup to the bucket the lookup key hashes to. Those coincide only for the partition index itself; for any other index of the type they are unrelated, so the pruned search read a bucket the record was not in.

Two silent consequences, both measured on main:

case (8 buckets, UNIQUE on the partition key tenant_id, UNIQUE on code) expected before
200 rows looked up by code, via TypeIndex.get and via SQL 200 found 0 found
same schema on round-robin (control) 200 found 200 found
38 inserts duplicating an existing code 38 rejected 6 accepted, 32 rejected as DuplicatedKeyException

The second one matters most: the commit-time duplicate check reads through the same pruned path, so a secondary UNIQUE index stopped enforcing its constraint.

Approach

The issue proposed guarding the single call site. This does something a bit broader instead, because the call site was not really the defect: getBucketIdByKeys(Object[] keyValues, boolean async) took an untyped key array with no statement of which properties those values described, so neither the caller nor the strategy could tell a partition key from an unrelated index's key.

The property names now go into the contract:

int getBucketIdByKeys(List<String> propertyNames, Object[] keyValues, boolean async);

A strategy whose placement depends on the key verifies the lookup covers exactly its own properties and returns -1 otherwise, which every caller already reads as "search every bucket" - correct, only slower. A strategy whose placement does not depend on the key (the planned TenantBucketSelectionStrategy in docs/multitenant.md §6.3 routes by session context) ignores the parameter and can still answer for any index. The mismatch becomes unrepresentable rather than guarded in one place.

Property matching is deliberately order-insensitive, since the hash both sides compute is a commutative sum over the per-value hash codes, and it is done with nested scans rather than a Set to stay allocation-free on a path that runs per query.

This also covers a case the issue did not mention: a partial key on a composite partition hashes fewer values than placement used. It cannot reach the strategy through get() today because the index contract rejects it first, but the guard covers it regardless.

Compatibility

  • Pruning on the partition key is unchanged, composite keys included. Both planner pruning rules (SelectExecutionPlanner, PartitionPruning) now pass their partition properties through the checked path.
  • The single-argument getBucketIdByKeys(Object[], boolean) and DocumentType.getBucketIndexByKeys(Object[], boolean) are deprecated and resolve to the unverifiable case, so they never prune.
  • The three-argument method is abstract, so a third-party strategy implementing only the old one gets a compile error rather than silently losing pruning. There are no implementations outside the three built-ins, and a custom key-partitioning strategy has the same latent bug, so surfacing it at build time is deliberate. Happy to switch it to a default returning -1 plus a one-time warning if reviewers prefer no build break at all.
  • Existing databases that ran partitioned(...) on a type with more than one index may already hold duplicates in a secondary UNIQUE index, admitted while the check read the wrong bucket. The constraint is enforced again from this change, but existing rows are not retro-validated; the release note tells operators to scan and REBUILD INDEX.

Tests

New PartitionedSecondaryIndexLookupTest (6 tests) pins the secondary-index lookup, the secondary UNIQUE constraint, the round-robin control, the surviving single-bucket pruning on the partition key, the fan-out on a non-partition key, and the composite partition key. Three of them fail on main.

Also run green: 939 tests in com.arcadedb.index.**, 319 in com.arcadedb.schema.**, and the SQL/Cypher partition-pruning suites, plus a full-reactor compile and test-compile.

…index lookup to one bucket

A type using `partitioned(...)` places a record in the bucket its PARTITION key hashes to, but
TypeIndex.getIndexesByKeys pruned every lookup to the bucket the LOOKUP key hashed to. Those coincide
only for the partition index itself; for any other index of the type they are unrelated, so the pruned
search read a bucket the record was not in. The lookup silently returned nothing, and since the
commit-time duplicate check reads the same path, a secondary UNIQUE index stopped rejecting duplicates.

Rather than guard the one call site, the bucket-selection contract now carries the property names the
key values belong to, so the mismatch cannot be expressed: getBucketIdByKeys(List, Object[], boolean)
lets a partitioning strategy check the lookup covers exactly its own properties and return -1 otherwise,
which callers already read as "search every bucket". That also covers a partial key on a composite
partition, which hashes fewer values than placement did. The single-argument overloads are deprecated
and resolve to the unverifiable case, so they never prune.

Pruning on the partition key is unchanged, composite keys included, and both planner pruning rules now
pass their partition properties through the checked path.

Tests: PartitionedSecondaryIndexLookupTest pins the secondary-index lookup, the secondary UNIQUE
constraint, the round-robin control, the surviving single-bucket pruning on the partition key, the
fan-out on a non-partition key, and the composite partition key.
@mergify

mergify Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Jul 30, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

🟢 Coverage 96.55% diff coverage · -7.55% coverage variation

Metric Results
Coverage variation -7.55% coverage variation
Diff coverage 96.55% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (c60958f) 147717 111689 75.61%
Head commit (10249c4) 179725 (+32008) 122321 (+10632) 68.06% (-7.55%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#5591) 29 28 96.55%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review: #5589 verify partition properties before pruning an index lookup

Solid fix with an excellent write-up. I traced the type contract across every call site and implementer and it holds together. Summary: approve, with a couple of minor/optional notes.

What I verified

  • Contract propagation is complete. The abstract getBucketIdByKeys(List, Object[], boolean) is implemented by ThreadBucketSelectionStrategy (base) and overridden in PartitionedBucketSelectionStrategy; RoundRobin inherits the base -1. super.getBucketIdByKeys(...) in the partitioned strategy resolves up to ThreadBucketSelectionStrategy returning -1, so there is no recursion risk. The only three implementers in the codebase are all updated.
  • No stale callers. The only non-test callers (TypeIndex, PartitionPruning, SelectExecutionPlanner, LocalDocumentType, RemoteDocumentType) all move to the 3-arg method; both planner rules pass their partition properties in the same order they filled keyValues, so the guard is satisfied and pruning is preserved.
  • Deprecated overloads are safe. getBucketIdByKeys(Object[], boolean) -> (null, ...) -> coversPartitionProperties(null,...) returns false -> -1 (fan out). RemoteDocumentTypeTest.getBucketIndexByKeys(null, false) still binds unambiguously to the 2-arg overload and still throws UnsupportedOperationException. Nothing breaks.
  • Order-insensitive matching is genuinely correct, not just convenient: because a record is placed by a commutative sum over per-value hashCodes, an index whose property set equals the partition set (in any order) hashes the same value for the same record, so pruning stays correct. Good call spelling this out in the Javadoc.
  • The isFullText and isNeedsRepartition() gates still short-circuit before the new path, so Rebuild index should validate entries in case of bucket selection strategy #832/feat: partition-aware planner pruning in SQL/Cypher+ partitioning integrity guardrails #4087 behavior is untouched.

Minor / optional

  1. coversPartitionProperties accepts duplicate lookup properties. For a composite partition [a, b], a lookupProperties of [a, a] (size 2, both members of the set) passes the check. This is not reachable today because index property lists are distinct, so it's purely defensive - but since the method is private and the contract is "exactly this property set", a one-line comment noting the distinct-properties assumption (or a cheap dup guard) would make the invariant explicit for future callers. Not blocking.
  2. if (propertyNames == null) is effectively dead. The constructor does Collections.unmodifiableList(propertyNames), which NPEs on null, so the field is never null. The new getBucketIdByKeys mirrors the existing pattern in getBucketIdByRecord, so it is stylistically consistent - just flagging that the fallback-to-super branch cannot actually execute.
  3. getPropertyNames() is now called on every lookup (moved out of the bucketIndex > -1 block), including the fan-out path. It delegates to a cached getFirstUnderlyingIndex().getPropertyNames(), so the cost is negligible - noting it only because the code sits on the per-query hot path.

On the abstract-vs-default question you raised

Making the 3-arg method abstract rather than default -1 is the right call for exactly the reason you gave: any external key-partitioning strategy has the same latent bug, and a compile break surfaces it. Since there are no third-party implementers in-tree, the blast radius is limited to intentional external strategies, which is where you want the signal.

Tests

Coverage is strong: secondary-index lookup, secondary UNIQUE enforcement, round-robin control, surviving single-bucket pruning on the partition key, fan-out on a non-partition key, and the composite partition key (full prunes, partial rejected by the index contract). The three that fail on main pin the regression well. The release note's guidance to scan + REBUILD INDEX existing secondary UNIQUE indexes is the right operator-facing follow-through.

Nice work - the "make the mismatch unrepresentable" framing is a cleaner fix than guarding the single call site.

…quality

Review follow-up on #5591.

coversPartitionProperties tested that every lookup property was also a partition property, which accepts a
repeated property standing in for a missing one: [a, a] passed against a partition of [a, b] even though it
sums a different pair of values than placement did, so it would have pruned to the wrong bucket. Unreachable
while no index declares a repeated property, but this method is the guard the fix rests on, so it now compares
occurrence counts and enforces the invariant rather than assuming callers uphold it. Still allocation-free:
partition keys hold one to three properties.

Also dropped the propertyNames == null branch. Both constructors assign a non-null list (the list constructor
NPEs on null inside Collections.unmodifiableList), so the fallback to super could never execute.

Test: aRepeatedLookupPropertyDoesNotPassForACompositePartition drives the strategy directly, since no query can
produce a repeated lookup property - covering the partition set, a permutation of it, the repeated property, and
the unverifiable null.
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review: fix(engine) #5589 - verify partition properties before pruning an index lookup

Reviewed the diff, the surrounding code (TypeIndex.getIndexesByKeys, the strategy hierarchy, both planner call sites), and the new test. This is a clean, well-reasoned fix with excellent documentation. Recommendation: approve.

Correctness - the root-cause framing is right. Widening getBucketIdByKeys to carry propertyNames rather than guarding the single call site is the correct call. The bug was structural: an untyped key array made "is this the partition key?" unanswerable at the type boundary, so a guard at one caller would leave the same trap for the next one. Moving the property names into the contract makes the mismatch unrepresentable.

Things I specifically checked and confirmed hold:

  • Hierarchy compiles. The new 3-arg method is abstract on the interface and concretely implemented by ThreadBucketSelectionStrategy (returns -1), inherited by RoundRobin, overridden by Partitioned. The old super.getBucketIdByKeys(...) chain is preserved through Thread.
  • Multiset equality is complete. coversPartitionProperties checks equal sizes plus per-partition-property occurrence counts. With equal sizes, matching counts for every partition property rules out an extra/foreign lookup property (it would force a missing partition property, which the loop catches). So [a,a] vs [a,b] and [a,x] vs [a,b] are both correctly rejected.
  • Order-insensitivity is safe because both placement and lookup hash a commutative sum over per-value hashCode()s, pinned by the permutation test.
  • All call sites migrated - both planners pass their partition-property lists, TypeIndex passes getPropertyNames(), and there are no remaining callers of the deprecated single-arg overloads.
  • getPropertyNames() order matches the declared index/partition order for single and composite cases, so the size/occurrence checks line up.

Test coverage. Strong. The suite pins the real symptom (secondary-index lookup returning 0), the more serious one (secondary UNIQUE no longer enforcing), a round-robin control, the surviving single-bucket optimization on the partition key, the fan-out on a non-partition key, and the composite-partition cases including direct multiset/null assertions against the strategy. Three failing on main is exactly what you want.

Minor notes (non-blocking):

  • coversPartitionProperties re-computes occurrencesOf(partitionProperty, propertyNames) inside the loop, so it is O(n^2) with a redundant self-scan. Fine at n<=3 and the allocation-free rationale is documented - just flagging it as intentionally quadratic in case someone later lifts the "1 to 3 properties" assumption.
  • The fix (like the pre-existing partition-index path) relies on a lookup key value hashing identically to the stored property value. That already held before this PR, so it is not a regression, but a numeric lookup key of a different boxed type than what is stored (e.g. Integer vs Long) would hash differently and miss the record. Worth keeping in mind; not introduced here.
  • Compatibility handling is thoughtful: deprecating the single-arg overloads to the never-prune path (correct, only slower) and making the 3-arg method abstract so a third-party key-partitioning strategy fails at build time rather than silently losing enforcement. Since such a strategy would carry the identical latent bug, surfacing it at compile time is the right trade-off. The offered default-returning-minus-1 fallback is reasonable if a hard build break concerns downstreams.
  • The release note correctly warns operators that existing partitioned(...) databases may already hold duplicates admitted under the old broken check and to REBUILD INDEX - the one piece that cannot be fixed automatically.

Nice work - the failure analysis in the description (the 6-accepted/32-rejected duplicate table especially) makes the severity and the fix easy to trust.

I could not execute the test suite in this environment (Maven is sandboxed), so the pass/fail claims above rest on code reading plus the results reported in the PR description.

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.65517% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.93%. Comparing base (157c3b5) to head (10249c4).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...onstrategy/PartitionedBucketSelectionStrategy.java 91.30% 0 Missing and 2 partials ⚠️
...cketselectionstrategy/BucketSelectionStrategy.java 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5591      +/-   ##
============================================
+ Coverage     65.90%   65.93%   +0.03%     
- Complexity     1114     1116       +2     
============================================
  Files          1751     1751              
  Lines        147670   147733      +63     
  Branches      31561    31577      +16     
============================================
+ Hits          97325    97414      +89     
+ Misses        37353    37327      -26     
  Partials      12992    12992              

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

Partitioned type: lookups on a secondary index are pruned to the wrong bucket, returning nothing and letting UNIQUE duplicates through

1 participant