Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ff7999e
chore(queryopt): add explicit priorities to existing optimizers
jzelinskie Apr 15, 2026
1f13802
feat(queryopt): implement absorption-idempotency optimizer mutation
jzelinskie Apr 15, 2026
c2d81d3
feat(queryopt): register absorption-idempotency optimizer
jzelinskie Apr 15, 2026
984ef96
chore(CHANGELOG): add absorption optimizer entry
jzelinskie Apr 15, 2026
3c69f91
feat(queryopt): add complement-absorption to union subsumption (A ∪ (…
jzelinskie Apr 15, 2026
4752935
feat(queryopt): add exclusion self-annihilation (A − A = ∅)
jzelinskie Apr 15, 2026
39fbed8
feat(queryopt): add intersection idempotency/absorption (A ∩ A = A, A…
jzelinskie Apr 15, 2026
9eeabbb
docs(queryopt): update absorption optimizer description with all five…
jzelinskie Apr 15, 2026
bf308fc
refactor(queryopt): dedup child iteration
jzelinskie Apr 16, 2026
d48db40
feat(queryopt): generalize exclusion annihilation to X − A = ∅ when X…
jzelinskie Apr 16, 2026
028f793
fix(NullPropagation): null if either arrow child is null
jzelinskie Apr 16, 2026
985fb54
test(NullPropagation): add comprehensive tests for all node types
jzelinskie Apr 16, 2026
15bd84c
feat(queryopt): add four new set-theoretic simplification rules
jzelinskie Apr 17, 2026
df4bc4a
refactor(queryopt): give each absorption rule its own function
jzelinskie Apr 17, 2026
b23ba30
rename(queryopt): absorption-idempotency → set-simplification
jzelinskie Apr 17, 2026
96c89b5
refactor(queryopt): use new outline.Equals alias
jzelinskie May 9, 2026
782828b
test(queryopt): add coverage for uncovered branches in set-simplifica…
jzelinskie May 9, 2026
7a927b1
fix: test w/ new api for registered optimizations
jzelinskie May 9, 2026
8074f0b
refactor: improve legibility of set simplification
jzelinskie May 9, 2026
40df164
test(queryopt): achieve 100% coverage in set-simplification
jzelinskie May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
- Add DispatchExecutor, a query plan executor that is Dispatch-aware and sends subproblems on Alias boundaries (https://github.com/authzed/spicedb/pull/3074)
- Implement Dispatch caching for query plan execution (https://github.com/authzed/spicedb/pull/3079)
- Add new optimizer to query planner based on set theory laws for simplifications (https://github.com/authzed/spicedb/pull/3051)

### Changed
- Build: strip quarantine attribute for MacOS (https://github.com/authzed/spicedb/pull/3082)
Expand Down
4 changes: 2 additions & 2 deletions pkg/query/mutations.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
// upward through the tree according to each node type's semantics:
// - Union: null if ALL children are null
// - Intersection: null if ANY child is null
// - Arrow/IntersectionArrow: null if the right child is null
// - Arrow/IntersectionArrow: null if either child is null
// - Exclusion: null if the left child is null
// - Caveat/Alias/Recursive: null if the only child is null
//
Expand All @@ -47,7 +47,7 @@
}

case ArrowIteratorType, IntersectionArrowIteratorType:
if len(outline.SubOutlines) == 2 && outline.SubOutlines[1].Type == NullIteratorType {
if len(outline.SubOutlines) == 2 && (outline.SubOutlines[0].Type == NullIteratorType || outline.SubOutlines[1].Type == NullIteratorType) {

Check warning on line 50 in pkg/query/mutations.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/mutations.go#L50

Added line #L50 was not covered by tests
return Outline{Type: NullIteratorType, ID: outline.ID}
}

Expand Down
262 changes: 262 additions & 0 deletions pkg/query/mutations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,268 @@ import (
"github.com/stretchr/testify/require"
)

func TestNullPropagation(t *testing.T) {
live := Outline{Type: FixedIteratorType}
null := Outline{Type: NullIteratorType}

// --- UnionIteratorType ---

t.Run("union: all null → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{null, null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("union: all null (3 children) → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{null, null, null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("union: one live child → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{live, null}})
require.Equal(t, UnionIteratorType, result.Type)
})

t.Run("union: mixed (3 children, 1 live) → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{null, live, null}})
require.Equal(t, UnionIteratorType, result.Type)
})

t.Run("union: all live → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{live, live}})
require.Equal(t, UnionIteratorType, result.Type)
})

t.Run("union: preserves ID when null", func(t *testing.T) {
result := NullPropagation(Outline{Type: UnionIteratorType, ID: 7, SubOutlines: []Outline{null, null}})
require.Equal(t, NullIteratorType, result.Type)
require.Equal(t, OutlineNodeID(7), result.ID)
})

// --- IntersectionIteratorType ---

t.Run("intersection: any null child → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{live, null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("intersection: null first child → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{null, live}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("intersection: all null → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{null, null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("intersection: one null in 3 children → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{live, null, live}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("intersection: all live → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{live, live}})
require.Equal(t, IntersectionIteratorType, result.Type)
})

t.Run("intersection: preserves ID when null", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionIteratorType, ID: 8, SubOutlines: []Outline{live, null}})
require.Equal(t, NullIteratorType, result.Type)
require.Equal(t, OutlineNodeID(8), result.ID)
})

// --- ArrowIteratorType ---

t.Run("arrow: null left child → null", func(t *testing.T) {
// Null → B has no sources to traverse, so result is empty.
result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{null, live}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("arrow: null right child → null", func(t *testing.T) {
// A → Null has no target to reach.
result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{live, null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("arrow: both children null → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{null, null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("arrow: neither child null → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{live, live}})
require.Equal(t, ArrowIteratorType, result.Type)
})

t.Run("arrow: wrong child count (1) → unchanged (defensive guard)", func(t *testing.T) {
result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{null}})
require.Equal(t, ArrowIteratorType, result.Type)
})

t.Run("arrow: preserves ID when null", func(t *testing.T) {
result := NullPropagation(Outline{Type: ArrowIteratorType, ID: 42, SubOutlines: []Outline{null, live}})
require.Equal(t, NullIteratorType, result.Type)
require.Equal(t, OutlineNodeID(42), result.ID)
})

// --- IntersectionArrowIteratorType ---

t.Run("intersection arrow: null left child → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{null, live}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("intersection arrow: null right child → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{live, null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("intersection arrow: both children null → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{null, null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("intersection arrow: neither child null → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{live, live}})
require.Equal(t, IntersectionArrowIteratorType, result.Type)
})

t.Run("intersection arrow: wrong child count (1) → unchanged (defensive guard)", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{null}})
require.Equal(t, IntersectionArrowIteratorType, result.Type)
})

t.Run("intersection arrow: wrong child count (3) with null child → unchanged (defensive guard)", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{null, live, live}})
require.Equal(t, IntersectionArrowIteratorType, result.Type)
})

t.Run("intersection arrow: preserves ID when null", func(t *testing.T) {
result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, ID: 9, SubOutlines: []Outline{null, live}})
require.Equal(t, NullIteratorType, result.Type)
require.Equal(t, OutlineNodeID(9), result.ID)
})

// --- ExclusionIteratorType ---

t.Run("exclusion: null left child (main set) → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{null, live}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("exclusion: null right child (excluded set) → unchanged (A − ∅ = A)", func(t *testing.T) {
// Subtracting the empty set is a no-op; the node stays for a later pass.
result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{live, null}})
require.Equal(t, ExclusionIteratorType, result.Type)
})

t.Run("exclusion: both null → null (left child triggers)", func(t *testing.T) {
result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{null, null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("exclusion: neither null → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{live, live}})
require.Equal(t, ExclusionIteratorType, result.Type)
})

t.Run("exclusion: wrong child count (1) → unchanged (defensive guard)", func(t *testing.T) {
result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{null}})
require.Equal(t, ExclusionIteratorType, result.Type)
})

t.Run("exclusion: preserves ID when null", func(t *testing.T) {
result := NullPropagation(Outline{Type: ExclusionIteratorType, ID: 5, SubOutlines: []Outline{null, live}})
require.Equal(t, NullIteratorType, result.Type)
require.Equal(t, OutlineNodeID(5), result.ID)
})

// --- CaveatIteratorType ---

t.Run("caveat: null child → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: CaveatIteratorType, SubOutlines: []Outline{null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("caveat: live child → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: CaveatIteratorType, SubOutlines: []Outline{live}})
require.Equal(t, CaveatIteratorType, result.Type)
})

t.Run("caveat: wrong child count (2) → unchanged (defensive guard)", func(t *testing.T) {
result := NullPropagation(Outline{Type: CaveatIteratorType, SubOutlines: []Outline{null, null}})
require.Equal(t, CaveatIteratorType, result.Type)
})

t.Run("caveat: preserves ID when null", func(t *testing.T) {
result := NullPropagation(Outline{Type: CaveatIteratorType, ID: 3, SubOutlines: []Outline{null}})
require.Equal(t, NullIteratorType, result.Type)
require.Equal(t, OutlineNodeID(3), result.ID)
})

// --- AliasIteratorType ---

t.Run("alias: null child → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: AliasIteratorType, SubOutlines: []Outline{null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("alias: live child → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: AliasIteratorType, SubOutlines: []Outline{live}})
require.Equal(t, AliasIteratorType, result.Type)
})

t.Run("alias: wrong child count (2) → unchanged (defensive guard)", func(t *testing.T) {
result := NullPropagation(Outline{Type: AliasIteratorType, SubOutlines: []Outline{null, null}})
require.Equal(t, AliasIteratorType, result.Type)
})

// --- RecursiveIteratorType ---

t.Run("recursive: null child → null", func(t *testing.T) {
result := NullPropagation(Outline{Type: RecursiveIteratorType, SubOutlines: []Outline{null}})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("recursive: live child → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: RecursiveIteratorType, SubOutlines: []Outline{live}})
require.Equal(t, RecursiveIteratorType, result.Type)
})

t.Run("recursive: wrong child count (2) → unchanged (defensive guard)", func(t *testing.T) {
result := NullPropagation(Outline{Type: RecursiveIteratorType, SubOutlines: []Outline{null, null}})
require.Equal(t, RecursiveIteratorType, result.Type)
})

// --- Leaf / unhandled types → always unchanged ---

t.Run("null node itself → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: NullIteratorType})
require.Equal(t, NullIteratorType, result.Type)
})

t.Run("datastore leaf → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: DatastoreIteratorType})
require.Equal(t, DatastoreIteratorType, result.Type)
})

t.Run("self leaf → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: SelfIteratorType})
require.Equal(t, SelfIteratorType, result.Type)
})

t.Run("fixed leaf → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: FixedIteratorType})
require.Equal(t, FixedIteratorType, result.Type)
})

t.Run("recursive sentinel leaf → unchanged", func(t *testing.T) {
result := NullPropagation(Outline{Type: RecursiveSentinelIteratorType})
require.Equal(t, RecursiveSentinelIteratorType, result.Type)
})
}

func TestReorderMutation(t *testing.T) {
t.Run("reorders children correctly", func(t *testing.T) {
child0 := Outline{Type: FixedIteratorType, Args: &IteratorArgs{FixedPaths: []Path{*MustPathFromString("document:doc0#viewer@user:alice")}}}
Expand Down
1 change: 1 addition & 0 deletions pkg/query/queryopt/caveat_pushdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ func init() {
Pushes caveat evalution to the lowest point in the tree.
Cannot push through intersection arrows
`,
Priority: 20,
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.

Do we have a means of constructing a list of optimizers at startup time rather than taking the registration approach? This smells like it's going to turn into z-index eventually.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely a place that could use work. I think for now it'd probably help to just have a central list where optimizers are registered, similar how to we register gRPC middleware. If it ever got too confusing doing that, it'd make sense to explicitly add "before" and "after" properties to each optimizer and let the system compute the order based on those (kind like systemd startup ordering)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jzelinskie I'd say we do the before/after now: its going to rapidly become untenable, so I recommend as a followup PR

NewTransform: func(_ RequestParams) OutlineTransform {
return func(outline query.Outline) query.Outline {
return query.MutateOutline(outline, []query.OutlineMutation{caveatPushdown})
Expand Down
1 change: 1 addition & 0 deletions pkg/query/queryopt/reachability_pruning.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func init() {
Replaces subtrees with NullIteratorType nodes when they can never
produce the target subject type of the request.
`,
Priority: 0,
NewTransform: func(params RequestParams) OutlineTransform {
return reachabilityPruning(params)
},
Expand Down
1 change: 1 addition & 0 deletions pkg/query/queryopt/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
func OptimizersForRequest(params RequestParams) []Optimizer {
base := []Optimizer{
optimizationRegistry["simple-caveat-pushdown"],
optimizationRegistry["set-simplification"],

Check warning on line 61 in pkg/query/queryopt/registry.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/queryopt/registry.go#L61

Added line #L61 was not covered by tests
optimizationRegistry["reachability-pruning"],
}

Expand Down
Loading
Loading