Skip to content

Commit 0298415

Browse files
authored
feat(qp): prune branches that cannot lead to the subject type of the … (#2968)
1 parent e49cc12 commit 0298415

File tree

8 files changed

+602
-12
lines changed

8 files changed

+602
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1010
### Changed
1111
- Updated CI so that Postgres tests run against v18 which is GA and not against v13 which is EOL (https://github.com/authzed/spicedb/pull/2926)
1212
- Added tracing to request validation (https://github.com/authzed/spicedb/pull/2950)
13+
- Query Planner optimization: in Check requests, prune branches that cannot lead to the subject type specified (https://github.com/authzed/spicedb/pull/2968)
1314

1415
### Fixed
1516
- Regression introduced in 1.49.2: missing spans in ReadSchema calls (https://github.com/authzed/spicedb/pull/2947)

internal/services/integrationtesting/query_plan_consistency_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ func runQueryPlanAssertions(t *testing.T, handle *queryPlanConsistencyHandle) {
135135

136136
// Apply static optimizations if requested
137137
if optimizationMode.optimize {
138-
co, err = queryopt.ApplyOptimizations(co, queryopt.StandardOptimzations)
138+
co, err = queryopt.ApplyOptimizations(co, queryopt.StandardOptimzations, queryopt.RequestParams{SubjectType: rel.Subject.ObjectType, SubjectRelation: rel.Subject.Relation})
139139
require.NoError(err)
140140
}
141141

internal/services/v1/permissions_queryplan.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ func (ps *permissionServer) checkPermissionWithQueryPlan(ctx context.Context, re
5858
return nil, ps.rewriteError(ctx, err)
5959
}
6060

61-
optimized, err := queryopt.ApplyOptimizations(co, queryopt.StandardOptimzations)
61+
optimized, err := queryopt.ApplyOptimizations(co, queryopt.StandardOptimzations, queryopt.RequestParams{
62+
SubjectType: req.Subject.Object.ObjectType,
63+
SubjectRelation: req.Subject.OptionalRelation,
64+
})
6265
if err != nil {
6366
return nil, ps.rewriteError(ctx, err)
6467
}

pkg/query/mutations.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,53 @@ func MutateOutline(outline Outline, fns []OutlineMutation) Outline {
1818
return result
1919
}
2020

21+
// NullPropagation is an OutlineMutation that propagates NullIteratorType nodes
22+
// upward through the tree according to each node type's semantics:
23+
// - Union: null if ALL children are null
24+
// - Intersection: null if ANY child is null
25+
// - Arrow/IntersectionArrow: null if the right child is null
26+
// - Exclusion: null if the left child is null
27+
// - Caveat/Alias/Recursive: null if the only child is null
28+
//
29+
// This is intended to run after a mutation that nullifies leaf nodes (e.g.
30+
// reachability pruning), so that the nulls cascade correctly through the tree
31+
// during the bottom-up walk.
32+
func NullPropagation(outline Outline) Outline {
33+
switch outline.Type {
34+
case UnionIteratorType:
35+
for _, sub := range outline.SubOutlines {
36+
if sub.Type != NullIteratorType {
37+
return outline
38+
}
39+
}
40+
return Outline{Type: NullIteratorType, ID: outline.ID}
41+
42+
case IntersectionIteratorType:
43+
for _, sub := range outline.SubOutlines {
44+
if sub.Type == NullIteratorType {
45+
return Outline{Type: NullIteratorType, ID: outline.ID}
46+
}
47+
}
48+
49+
case ArrowIteratorType, IntersectionArrowIteratorType:
50+
if len(outline.SubOutlines) == 2 && outline.SubOutlines[1].Type == NullIteratorType {
51+
return Outline{Type: NullIteratorType, ID: outline.ID}
52+
}
53+
54+
case ExclusionIteratorType:
55+
if len(outline.SubOutlines) == 2 && outline.SubOutlines[0].Type == NullIteratorType {
56+
return Outline{Type: NullIteratorType, ID: outline.ID}
57+
}
58+
59+
case CaveatIteratorType, AliasIteratorType, RecursiveIteratorType:
60+
if len(outline.SubOutlines) == 1 && outline.SubOutlines[0].Type == NullIteratorType {
61+
return Outline{Type: NullIteratorType, ID: outline.ID}
62+
}
63+
}
64+
65+
return outline
66+
}
67+
2168
// ReorderMutation returns an OutlineMutation that reorders SubOutlines according
2269
// to order, where order[i] is the index of the child to place at position i.
2370
// If order has a different length than the node's SubOutlines, it is a no-op.

pkg/query/queryopt/caveat_pushdown.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ func init() {
99
Pushes caveat evalution to the lowest point in the tree.
1010
Cannot push through intersection arrows
1111
`,
12-
Mutation: caveatPushdown,
12+
NewTransform: func(_ RequestParams) OutlineTransform {
13+
return func(outline query.Outline) query.Outline {
14+
return query.MutateOutline(outline, []query.OutlineMutation{caveatPushdown})
15+
}
16+
},
1317
})
1418
}
1519

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package queryopt
2+
3+
import (
4+
"github.com/authzed/spicedb/pkg/query"
5+
)
6+
7+
func init() {
8+
MustRegisterOptimization(Optimizer{
9+
Name: "reachability-pruning",
10+
Description: `
11+
Replaces subtrees with NullIteratorType nodes when they can never
12+
produce the target subject type of the request.
13+
`,
14+
NewTransform: func(params RequestParams) OutlineTransform {
15+
return reachabilityPruning(params)
16+
},
17+
})
18+
}
19+
20+
func reachabilityPruning(params RequestParams) func(outline query.Outline) query.Outline {
21+
return func(outline query.Outline) query.Outline {
22+
if params.SubjectType == "" || (params.SubjectRelation != "" && params.SubjectRelation != "...") {
23+
// do not mutate if subjectType is empty or if subjectRelation is non-empty
24+
return outline
25+
}
26+
// Pre-pass: find all node IDs inside arrow left subtrees.
27+
// These are intermediate hops whose subject type does not need
28+
// to match the target, so they must be excluded from pruning.
29+
immune := collectArrowLeftSubtreeIDs(outline)
30+
return query.MutateOutline(outline, []query.OutlineMutation{
31+
leafSubjectTypePruner(params.SubjectType, immune),
32+
query.NullPropagation,
33+
})
34+
}
35+
}
36+
37+
// leafSubjectTypePruner returns an OutlineMutation that replaces leaf outline
38+
// nodes (DatastoreIteratorType, SelfIteratorType) with NullIteratorType when
39+
// their subject type does not match the target. Nodes whose IDs appear in the
40+
// immune set are skipped — these are nodes inside arrow left subtrees whose
41+
// subject types are intermediate hops, not final outputs.
42+
//
43+
// Null propagation through compound nodes (unions, intersections, arrows, etc.)
44+
// is handled by query.NullPropagation, which should run after this mutation in
45+
// the same MutateOutline call.
46+
func leafSubjectTypePruner(targetSubjectType string, immune map[query.OutlineNodeID]bool) query.OutlineMutation {
47+
return func(outline query.Outline) query.Outline {
48+
if immune[outline.ID] {
49+
return outline
50+
}
51+
52+
switch outline.Type {
53+
case query.DatastoreIteratorType:
54+
if outline.Args != nil && outline.Args.Relation != nil && outline.Args.Relation.Type() == targetSubjectType {
55+
return outline
56+
}
57+
return query.Outline{Type: query.NullIteratorType, ID: outline.ID}
58+
59+
case query.SelfIteratorType:
60+
if outline.Args != nil && outline.Args.DefinitionName == targetSubjectType {
61+
return outline
62+
}
63+
return query.Outline{Type: query.NullIteratorType, ID: outline.ID}
64+
65+
default:
66+
return outline
67+
}
68+
}
69+
}
70+
71+
// collectArrowLeftSubtreeIDs walks the outline tree and collects the IDs of all
72+
// nodes that appear inside the left subtree of an arrow (or intersection arrow).
73+
// These nodes have intermediate subject types that should not be pruned.
74+
func collectArrowLeftSubtreeIDs(outline query.Outline) map[query.OutlineNodeID]bool {
75+
ids := make(map[query.OutlineNodeID]bool)
76+
_ = query.WalkOutlinePreOrder(outline, func(node query.Outline) error {
77+
if (node.Type == query.ArrowIteratorType || node.Type == query.IntersectionArrowIteratorType) && len(node.SubOutlines) == 2 {
78+
markAllIDs(node.SubOutlines[0], ids)
79+
}
80+
return nil
81+
})
82+
return ids
83+
}
84+
85+
// markAllIDs recursively adds the ID of every node in the subtree to the set.
86+
func markAllIDs(outline query.Outline, ids map[query.OutlineNodeID]bool) {
87+
ids[outline.ID] = true
88+
for _, sub := range outline.SubOutlines {
89+
markAllIDs(sub, ids)
90+
}
91+
}

0 commit comments

Comments
 (0)