Repository navigation
Fix CountStrategy returning capped counts when the count is observed - #3717
upadhyay-prashant wants to merge 1 commit into
Conversation
fa77f16 to
1b32af4
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3717 +/- ##
============================================
+ Coverage 76.35% 76.77% +0.41%
- Complexity 13424 14365 +941
============================================
Files 1012 1037 +25
Lines 60341 64834 +4493
Branches 7075 7708 +633
============================================
+ Hits 46076 49774 +3698
- Misses 11548 11970 +422
- Partials 2717 3090 +373 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
1b32af4 to
eb17ead
Compare
| {__.count().is(P.between(0, 10)), __.limit(10).count().is(P.between(0, 10))}, | ||
| {__.count().is(P.between(5, 10)), __.limit(10).count().is(P.between(5, 10))}, | ||
| {__.count().is(P.gt(10).or(lt(5))), __.limit(11).count().is(P.gt(10).or(lt(5)))}, | ||
| {__.count().is(P.gt(10).or(lt(5))), __.count().is(P.gt(10).or(lt(5)))}, // capped count of 11 would pass and be returned |
There was a problem hiding this comment.
I find it surprising that the changes result in this case dropping the limit(), but the limit is retained in all of the above cases:
{__.count().is(-2023), __.limit(0).count().is(-2023)},
{__.count().is(P.between(0, 10)), __.limit(10).count().is(P.between(0, 10))},
{__.count().is(P.between(5, 10)), __.limit(10).count().is(P.between(5, 10))},
My expectation would be that the limit no longer gets added in all of these basic count().is(...) cases.
There was a problem hiding this comment.
The difference is whether the capped count can pass the predicate. If it can't, any count that does pass is below the cap and therefore exact, so the limit is harmless even at the root:
is(-2023):limit(0)gives a count of 0, which fails the predicate (and a count can never be negative anyway).between(0, 10)/between(5, 10): the upper bound is exclusive, so the cap of 10 fails.gt(10).or(lt(5)): the cap of 11 passesgt(10), so a traversal of 20 elements would return 11. That's the case the PR fixes.
Dropping the limit for every root-level case would be simpler, but it would throw away optimizations that are always safe. I've added a comment above these cases in the test to explain this.
| {__.filter(__.out().out().count().is(0)), __.not(__.out().out())}, | ||
| {__.local(aggregate("x")).count().is(0).as("a"), __.local(aggregate("x")).limit(1).count().is(0).as("a")}, | ||
| {__.out().count().as("a").is(0), __.out().limit(1).count().as("a").is(0)}, | ||
| {__.out().count().is(neq(4)), __.out().limit(5).count().is(neq(4))}, |
There was a problem hiding this comment.
For each test case we are updating by removing the limit(), I think I'd like to reintroduce a new case with a similar traversal and predicate, but where we still expect a limit to be added. In this case, it could be something like this:
{__.out().where(__.count().is(neq(4))), __.out().where(__.limit(5).count().is(neq(4)))},
I'd like to retain decent coverage of "positive" cases where CountStrategy is still productive following this change.
There was a problem hiding this comment.
Agreed, and done. Every case whose expectation changed now has a counterpart that is still limited:
out().count().is(neq(4))->where(out().count().is(neq(4)))and yourout().where(count().is(neq(4))), bothlimit(5)is(gt(2))->filter(out().count().is(gt(2))),limit(3)is(gte(2))->filter(out().count().is(gte(2))),limit(2)is(outside(2, 4))->where(out().count().is(outside(2, 4))),limit(5)is(gt(10).or(lt(5)))->where(count().is(gt(10).or(lt(5)))),limit(11)LazyBarrierStrategyTestrootis(gt(10))-> awhere()version that keepslimit(11)
| // a capped count that passes is() must not be observed by later steps or the parent | ||
| {__.where(__.out().count().is(gt(1)).is(lt(3))), __.where(__.out().count().is(gt(1)).is(lt(3)))}, | ||
| {__.where(__.out().count().is(gte(2)).is(lte(2))), __.where(__.out().count().is(gte(2)).is(lte(2)))}, | ||
| {__.filter(__.out().count().is(neq(1)).is(lt(5))), __.filter(__.out().count().is(neq(1)).is(lt(5)))}, | ||
| {__.where(__.out().count().is(gt(1)).as("a")), __.where(__.out().count().is(gt(1)).as("a"))}, | ||
| {__.where(__.out().count().as("a").is(gt(1))), __.where(__.out().count().as("a").is(gt(1)))}, |
There was a problem hiding this comment.
I may be missing some important detail here, but in my mind this sort of where()/filter() nesting is the classic example in which the limit() injection optimization is safe. The where()/filter() steps are only testing whether the child traversal is productive, so the specific value of the returned count has no impact on if the filter is true or false.
There was a problem hiding this comment.
You're right. Treating labels as "observed" was overly conservative, and I've removed that check. In a filter-only child, labels can't be read from outside it, so where(out().count().as("a").is(gt(1))) and both filter(...) label cases now get the limit.
The one exception is a label at the end of the where() child, e.g. where(out().count().is(gt(1)).as("a")). That makes where() build a WhereTraversalStep, which appends a WhereEndStep that compares the count to the path value "a", so the capped value would be observed. That case is still caught by the existing "a step follows is()" check, so no label-specific logic is needed.
The cases that still must not be limited inside where()/filter() are the ones with a following is(), e.g. where(out().count().is(gt(1)).is(lt(3))). The second is() sees the capped value. The new CountStrategy.feature scenarios show the wrong results this caused on the modern graph.
Looking at where a label could actually be read from also turned up one more case: the is() predicate itself can hold a traversal (P.gt(__.constant(29))), and that traversal runs inside the child. For example, filter(outE().count().is(gt(1).and(lt(__.constant(3))))) returned marko as well as josh, and outE().count().as("a").is(gt(1).and(lte(__.select("a")))) returned 2 instead of 6. The strategy only sees the literal part of the predicate, so it can't derive a safe limit. CountStrategy now leaves count().is(P) alone whenever P contains a traversal. This was already broken before this PR, but it's the same class of bug, so I've included the fix and two feature scenarios for it here. A narrower rule (keep the limit when an and() has a literal part that rejects the cap) would be possible as a follow-up, but it needs careful handling of and/or/not with unknown values, so I kept it simple here.
eb17ead to
08d840e
Compare
CountStrategy rewrote count().is(P) to limit(n).count().is(P). When P accepts n, the capped value was emitted in place of the real count, so a following is(), the root traversal or a value-consuming parent saw the wrong number, e.g. where(out().count().is(gt(1)).is(lt(3))) kept vertices with more than two edges and out().count().is(gt(2)) returned 3. The limit is now only added when P rejects the cap, or when the count is used purely as a filter (where/filter, not, and/or, sideEffect, repeat until/emit) and no step follows is(). A label at the end of a where() child adds a WhereEndStep that compares the count to the path, so that case is not limited; other labels in a filter child can't be observed. The strategy also no longer touches count().is(P) when P contains a traversal, e.g. is(gt(1).and(lt(__.constant(3)))), since that bound is only resolved at runtime and no safe limit can be derived. Each case whose expectation changed has a counterpart that is still limited, so the productive cases stay covered.
08d840e to
2865a74
Compare
Summary
CountStrategyrewritescount().is(P)tolimit(n).count().is(P). WhenPacceptsn, the capped value is emitted in place of the real count, so anything that observes it gets a wrong result:g.V().where(outE().count().is(gt(1)).is(lt(3)))keeps vertices with more than 2 edgesg.V().outE().count().is(gt(2))returns3instead of6g.V().map(outE().count().is(gt(1)))returns2instead of3The limit is now only added when:
Prejects the cap (e.g.lt,lte,within), so any count that passes is exact; orwhere/filter,not,and/or,sideEffect,repeat().until()/emit()) and no step followsis(). A label at the end of awhere()child adds aWhereEndStepthat compares the count to the path, so that case is not limited.The strategy also leaves
count().is(P)alone whenPcontains a traversal (e.g.is(gt(1).and(lt(__.constant(3))))). That bound is only resolved at runtime, so no safe limit can be derived. This was already broken before this PR:filter(outE().count().is(gt(1).and(lt(__.constant(3)))))returned marko as well as josh, andoutE().count().as("a").is(gt(1).and(lte(__.select("a"))))returned2instead of6. A narrower rule (keep the limit when anand()has a literal part that rejects the cap) is possible as a follow-up.Otherwise the traversal is left unchanged. The existing
not()/ filter-removal rewrites are untouched.Testing
CountStrategyTest: added cases covering shapes that must not be limited and shapes that must stay optimized; every case whose expectation changed has a still-limited counterpart (e.g.out().count().is(neq(4))vswhere(out().count().is(neq(4)))). Also covers traversal-valued predicates in root, filter-only, label-reading andnot()forms.LazyBarrierStrategyTest/TraversalExplanationTest: updated two expectations that relied on the old root-level /by()rewrite;LazyBarrierStrategyTestalso gained awhere()counterpart.CountStrategy.feature: added 6 scenarios, all of which produced wrong results before the fix (except thelt(3).is(gt(1))control) and pass after.gremlin-coresuite andtinkergraph-gremlin(including its feature suites): 0 failures.translations.jsonand the Go/Python/.NET/JS translation files (additions only). GLV feature tests were not run locally.Notes
count().is(gt(n))no longer get the earlylimit(). That is required for correctness.3.7-dev/3.8-dev; this PR targetsmaster.