Repository navigation
perf(datasets): batch wildcard exclusions in subject set subtraction #3395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,6 @@ package datasets | |
|
|
||
| import ( | ||
| "maps" | ||
| "slices" | ||
|
|
||
| "github.com/authzed/spicedb/internal/caveats" | ||
| core "github.com/authzed/spicedb/pkg/proto/core/v1" | ||
|
|
@@ -144,9 +143,54 @@ func (bss BaseSubjectSet[T]) Subtract(toRemove T) { | |
| // SubtractAll subtracts the other set of subjects from this set of subtracts, modifying this | ||
| // set *in place*. | ||
| func (bss BaseSubjectSet[T]) SubtractAll(other BaseSubjectSet[T]) { | ||
| for _, otherSubject := range other.AsSlice() { | ||
| bss.Subtract(otherSubject) | ||
| // Batch concrete removals from a wildcard so we only copy its exclusion list | ||
| // once. The wildcard's caveat stays the same; removals affect its exclusions. | ||
| // | ||
| // Fall back to Subtract if there is no wildcard to update, or if other has a | ||
| // wildcard. Subtracting a wildcard can remove this set's wildcard and turn | ||
| // the other wildcard's exclusions into concrete results. A single concrete | ||
| // removal doesn't need batching. | ||
| wildcard, hasWildcard := bss.wildcard.get() | ||
| if !hasWildcard || other.wildcard.getOrNil() != nil || len(other.concrete) < 2 { | ||
| for _, subject := range other.AsSlice() { | ||
| bss.Subtract(subject) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| // The exclusion slice may be shared with a cloned set, so allocate a new one. | ||
| // Track existing exclusions that also appear in other so the second pass | ||
| // doesn't add them again. | ||
| existingExclusions := wildcard.GetExcludedSubjects() | ||
| exclusions := make([]T, 0, len(existingExclusions)+len(other.concrete)) | ||
| matched := make(map[string]struct{}, min(len(existingExclusions), len(other.concrete))) | ||
| for _, exclusion := range existingExclusions { | ||
| if removing, ok := other.concrete[exclusion.GetSubjectId()]; ok { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ditto in here for comments
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a caveat example here, including the unconditional case, and explained why we keep both source subjects. |
||
| // Either caveat can exclude the subject, so combine them with OR. If | ||
| // either has no caveat, the result is an unconditional exclusion: | ||
| // {* - {tom[c1]}} - {tom[c2], sarah} => {* - {tom[c1 || c2], sarah}} | ||
| // Keep both source subjects so the constructor can combine their bookkeeping. | ||
| matched[exclusion.GetSubjectId()] = struct{}{} | ||
| exclusion = bss.constructor(exclusion.GetSubjectId(), shortcircuitedOr(exclusion.GetCaveatExpression(), removing.GetCaveatExpression()), nil, exclusion, removing) | ||
| } | ||
| exclusions = append(exclusions, exclusion) | ||
| } | ||
|
|
||
| for subjectID, removing := range other.concrete { | ||
| if _, ok := matched[subjectID]; !ok { | ||
| // Keep the removed subject's caveat and bookkeeping for a new exclusion. | ||
| exclusions = append(exclusions, removing) | ||
| } | ||
| if existing, ok := bss.concrete[subjectID]; ok { | ||
| // The set can also contain this subject as a concrete entry. Remove it | ||
| // too, or keep it only when its caveat holds and the removal's does not. | ||
| bss.setConcrete(subjectID, subtractConcreteFromConcrete(existing, removing, bss.constructor)) | ||
| } | ||
| } | ||
|
|
||
| // Only the exclusions changed; keep the wildcard's own caveat and bookkeeping. | ||
| updated := bss.constructor(tuple.PublicWildcard, wildcard.GetCaveatExpression(), exclusions, wildcard) | ||
| bss.wildcard.setOrNil(&updated) | ||
| } | ||
|
|
||
| // MustIntersectionDifference performs an intersection between this set and the other set, modifying | ||
|
|
@@ -257,8 +301,19 @@ func (bss BaseSubjectSet[T]) IsEmpty() bool { | |
|
|
||
| // AsSlice returns the contents of the subject set as a slice of found subjects. | ||
| func (bss BaseSubjectSet[T]) AsSlice() []T { | ||
| values := slices.Collect(maps.Values(bss.concrete)) | ||
| if wildcard, ok := bss.wildcard.get(); ok { | ||
| wildcard, hasWildcard := bss.wildcard.get() | ||
| count := len(bss.concrete) | ||
| if hasWildcard { | ||
| count++ | ||
| } | ||
| if count == 0 { | ||
| return nil | ||
| } | ||
| values := make([]T, 0, count) | ||
| for _, concrete := range bss.concrete { | ||
| values = append(values, concrete) | ||
| } | ||
| if hasWildcard { | ||
| values = append(values, wildcard) | ||
| } | ||
| return values | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| package datasets | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "testing" | ||
|
|
||
| "google.golang.org/protobuf/proto" | ||
|
|
||
| "github.com/authzed/spicedb/internal/testutil" | ||
| core "github.com/authzed/spicedb/pkg/proto/core/v1" | ||
| v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1" | ||
| ) | ||
|
|
||
| func TestBatchedSubtract(t *testing.T) { | ||
| tcs := []struct { | ||
| name string | ||
| startingSubjects []*v1.FoundSubject | ||
| toSubtract []*v1.FoundSubject | ||
| expected []*v1.FoundSubject | ||
| }{ | ||
| { | ||
| "subtract two subjects from a bare wildcard", | ||
| []*v1.FoundSubject{wc()}, | ||
| []*v1.FoundSubject{sub("alice"), sub("bob")}, | ||
| []*v1.FoundSubject{wc("alice", "bob")}, | ||
| }, | ||
| { | ||
| "retain an existing exclusion when adding new exclusions", | ||
| []*v1.FoundSubject{wc("carol")}, | ||
| []*v1.FoundSubject{sub("alice"), sub("bob")}, | ||
| []*v1.FoundSubject{wc("alice", "bob", "carol")}, | ||
| }, | ||
| { | ||
| "merge overlapping exclusions without duplicates", | ||
| []*v1.FoundSubject{wc("alice", "carol")}, | ||
| []*v1.FoundSubject{sub("alice"), sub("bob")}, | ||
| []*v1.FoundSubject{wc("alice", "bob", "carol")}, | ||
| }, | ||
| { | ||
| "all removed subjects are already excluded", | ||
| []*v1.FoundSubject{wc("alice", "bob")}, | ||
| []*v1.FoundSubject{sub("alice"), sub("bob")}, | ||
| []*v1.FoundSubject{wc("alice", "bob")}, | ||
| }, | ||
| { | ||
| "preserve the wildcard caveat and new exclusion caveats", | ||
| []*v1.FoundSubject{cwc(caveatexpr("public"))}, | ||
| []*v1.FoundSubject{csub("alice", caveatexpr("banned")), csub("bob", caveatexpr("suspended"))}, | ||
| []*v1.FoundSubject{ | ||
| cwc(caveatexpr("public"), | ||
| csub("alice", caveatexpr("banned")), | ||
| csub("bob", caveatexpr("suspended")), | ||
| ), | ||
| }, | ||
| }, | ||
| { | ||
| "combine overlapping exclusion caveats with OR", | ||
| []*v1.FoundSubject{cwc(nil, csub("alice", caveatexpr("banned")), csub("carol", caveatexpr("suspended")))}, | ||
| []*v1.FoundSubject{csub("alice", caveatexpr("suspended")), sub("bob")}, | ||
| []*v1.FoundSubject{ | ||
| cwc(nil, | ||
| csub("alice", caveatOr(caveatexpr("banned"), caveatexpr("suspended"))), | ||
| sub("bob"), | ||
| csub("carol", caveatexpr("suspended")), | ||
| ), | ||
| }, | ||
| }, | ||
| { | ||
| "unconditional removal replaces a conditional exclusion", | ||
| []*v1.FoundSubject{cwc(nil, csub("alice", caveatexpr("banned")))}, | ||
| []*v1.FoundSubject{sub("alice"), sub("bob")}, | ||
| []*v1.FoundSubject{wc("alice", "bob")}, | ||
| }, | ||
| { | ||
| "conditional removal preserves an unconditional exclusion", | ||
| []*v1.FoundSubject{wc("alice")}, | ||
| []*v1.FoundSubject{csub("alice", caveatexpr("banned")), sub("bob")}, | ||
| []*v1.FoundSubject{wc("alice", "bob")}, | ||
| }, | ||
| { | ||
| "remove a matching concrete subject and retain an unrelated subject", | ||
| []*v1.FoundSubject{wc(), sub("alice"), sub("carol")}, | ||
| []*v1.FoundSubject{sub("alice"), sub("bob")}, | ||
| []*v1.FoundSubject{wc("alice", "bob"), sub("carol")}, | ||
| }, | ||
| { | ||
| "conditional removal updates both the concrete subject and wildcard", | ||
| []*v1.FoundSubject{cwc(caveatexpr("public")), csub("alice", caveatexpr("member"))}, | ||
| []*v1.FoundSubject{csub("alice", caveatexpr("banned")), sub("bob")}, | ||
| []*v1.FoundSubject{ | ||
| cwc(caveatexpr("public"), csub("alice", caveatexpr("banned")), sub("bob")), | ||
| csub("alice", caveatAnd(caveatexpr("member"), caveatInvert(caveatexpr("banned")))), | ||
| }, | ||
| }, | ||
| { | ||
| "subtract a wildcard alongside concrete subjects", | ||
| []*v1.FoundSubject{wc("carol")}, | ||
| []*v1.FoundSubject{wc("alice"), sub("bob"), sub("dave")}, | ||
| []*v1.FoundSubject{sub("alice")}, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range tcs { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| set := NewSubjectSet() | ||
| for _, subject := range tc.startingSubjects { | ||
| set.MustAdd(subject) | ||
| } | ||
| toRemove := NewSubjectSet() | ||
| for _, subject := range tc.toSubtract { | ||
| toRemove.MustAdd(subject) | ||
| } | ||
|
|
||
| set.SubtractAll(toRemove) | ||
| testutil.RequireEquivalentSets(t, tc.expected, set.AsSlice()) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestBatchedSubtractMatchesSequential(t *testing.T) { | ||
| expressions := []*core.CaveatExpression{nil, caveatexpr("a"), caveatexpr("b")} | ||
| for wi, wexpr := range expressions { | ||
| for ei, eexpr := range expressions { | ||
| for ri, rexpr := range expressions { | ||
| for _, count := range []int{0, 1, 2, 10} { | ||
| t.Run(fmt.Sprintf("%d/%d/%d/%d", wi, ei, ri, count), func(t *testing.T) { | ||
| initial := NewSubjectSet() | ||
| initial.MustAdd(cwc(wexpr, csub("u0", eexpr), csub("retained", eexpr))) | ||
| initial.MustAdd(csub("u1", caveatexpr("c"))) | ||
| initial.MustAdd(sub("unrelated")) | ||
| removing := NewSubjectSet() | ||
| for i := range count { | ||
| removing.MustAdd(csub(fmt.Sprintf("u%d", i), rexpr)) | ||
| } | ||
| original := cloneSubjects(initial.AsSlice()) | ||
| reference := initial.Clone() | ||
| actual := initial.Clone() | ||
| beforeRemoving := cloneSubjects(removing.AsSlice()) | ||
| for _, subject := range removing.AsSlice() { | ||
| reference.Subtract(subject) | ||
| } | ||
| actual.SubtractAll(removing) | ||
| testutil.RequireEquivalentSets(t, reference.AsSlice(), actual.AsSlice()) | ||
| testutil.RequireEquivalentSets(t, original, initial.AsSlice()) | ||
| testutil.RequireEquivalentSets(t, beforeRemoving, removing.AsSlice()) | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| for _, wildcard := range []*v1.FoundSubject{wc(), cwc(caveatexpr("a"), csub("u0", caveatexpr("b")))} { | ||
| initial := NewSubjectSet() | ||
| initial.MustAdd(wildcard) | ||
| initial.MustAdd(csub("u1", caveatexpr("c"))) | ||
| reference := initial.Clone() | ||
| actual := initial.Clone() | ||
| for _, subject := range reference.AsSlice() { | ||
| reference.Subtract(subject) | ||
| } | ||
| actual.SubtractAll(actual) | ||
| testutil.RequireEquivalentSets(t, reference.AsSlice(), actual.AsSlice()) | ||
| } | ||
| } | ||
|
|
||
| func cloneSubjects(subjects []*v1.FoundSubject) []*v1.FoundSubject { | ||
| cloned := make([]*v1.FoundSubject, len(subjects)) | ||
| for i, subject := range subjects { | ||
| cloned[i] = proto.Clone(subject).(*v1.FoundSubject) | ||
| } | ||
| return cloned | ||
| } | ||
|
|
||
| func BenchmarkSubjectSetSubtractAll(b *testing.B) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add some pre-defined, readable unit tests (in their own table-driven test)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. Added a table-driven test with the inputs and expected results written out for each case. |
||
| for _, tc := range []struct { | ||
| name string | ||
| existingCount, removalCount, offset int | ||
| }{ | ||
| {"wildcard/5000", 0, 5000, 0}, | ||
| {"existing5000/disjoint10", 5000, 10, 5000}, | ||
| {"existing5000/overlap10", 5000, 10, 0}, | ||
| {"existing5000/overlap5000", 5000, 5000, 0}, | ||
| } { | ||
| b.Run(tc.name, func(b *testing.B) { | ||
| excluded := make([]string, tc.existingCount) | ||
| for i := range excluded { | ||
| excluded[i] = fmt.Sprintf("u%d", i) | ||
| } | ||
| wildcard := wc(excluded...) | ||
| removing := NewSubjectSet() | ||
| for i := range tc.removalCount { | ||
| removing.MustAdd(sub(fmt.Sprintf("u%d", tc.offset+i))) | ||
| } | ||
| b.ReportAllocs() | ||
| for b.Loop() { | ||
| set := NewSubjectSet() | ||
| set.MustAdd(wildcard) | ||
| set.SubtractAll(removing) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a comment explaining here exactly what's being done: BaseSubjectSet is very complex, so we require detailed comments
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added an explanation of which cases can be batched and why subtracting another wildcard stays on the existing path.