Skip to content

Commit 2843a46

Browse files
committed
fix(query): fix arrow reversal tagging the subrelation incorrectly
1 parent b275f07 commit 2843a46

File tree

4 files changed

+177
-10
lines changed

4 files changed

+177
-10
lines changed

pkg/query/arrow.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,14 @@ func (a *ArrowIterator) checkRightToLeft(ctx *Context, resources []Object, subje
142142
}
143143
rightPathCount++
144144

145-
// rightPath.Resource is an intermediate object from the right side
146-
// Now check if any of our input resources connect to this intermediate via left
145+
// rightPath.Resource is an intermediate object from the right side.
146+
// Now check if any of our input resources connect to this intermediate via left.
147+
// Use tuple.Ellipsis as the relation since the left side stores subjects with "..."
148+
// (the canonical relation for direct membership with no subrelation).
147149
intermediateAsSubject := ObjectAndRelation{
148150
ObjectType: rightPath.Resource.ObjectType,
149151
ObjectID: rightPath.Resource.ObjectID,
150-
Relation: "",
152+
Relation: tuple.Ellipsis,
151153
}
152154

153155
leftSeq, err := ctx.Check(a.left, resources, intermediateAsSubject)

pkg/query/arrow_reversal_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package query
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/authzed/spicedb/internal/datastore/common"
11+
"github.com/authzed/spicedb/internal/datastore/memdb"
12+
"github.com/authzed/spicedb/pkg/datalayer"
13+
"github.com/authzed/spicedb/pkg/datastore"
14+
"github.com/authzed/spicedb/pkg/schema/v2"
15+
"github.com/authzed/spicedb/pkg/schemadsl/compiler"
16+
"github.com/authzed/spicedb/pkg/schemadsl/input"
17+
"github.com/authzed/spicedb/pkg/tuple"
18+
)
19+
20+
// TestDoubleWideArrowAdvisedMatchesPlain builds the file→org→group→user
21+
// double-arrow hierarchy, checks that the plain (LTR) plan finds paths from
22+
// file0 to user42, then applies a CountAdvisor (which may flip one or both
23+
// arrow directions to RTL) and asserts the advised plan returns the same set
24+
// of paths.
25+
func TestDoubleWideArrowAdvisedMatchesPlain(t *testing.T) {
26+
t.Parallel()
27+
28+
const (
29+
numFiles = 5
30+
numOrgs = 29 // prime
31+
numGroups = 97 // prime
32+
numUsers = 997 // prime
33+
orgsPerFile = 4
34+
groupsPerOrg = 10
35+
usersPerGroup = 15
36+
)
37+
38+
rawDS, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC)
39+
require.NoError(t, err)
40+
41+
ctx := context.Background()
42+
43+
schemaText := `
44+
definition user {}
45+
46+
definition group {
47+
relation member: user
48+
}
49+
50+
definition org {
51+
relation group: group
52+
permission member = group->member
53+
}
54+
55+
definition file {
56+
relation org: org
57+
relation view: user
58+
permission viewer = view + org->member
59+
}
60+
`
61+
62+
compiled, err := compiler.Compile(compiler.InputSchema{
63+
Source: input.Source("test"),
64+
SchemaString: schemaText,
65+
}, compiler.AllowUnprefixedObjectType())
66+
require.NoError(t, err)
67+
68+
_, err = rawDS.ReadWriteTx(ctx, func(ctx context.Context, rwt datastore.ReadWriteTransaction) error {
69+
return rwt.LegacyWriteNamespaces(ctx, compiled.ObjectDefinitions...)
70+
})
71+
require.NoError(t, err)
72+
73+
relationships := make([]tuple.Relationship, 0,
74+
numFiles*orgsPerFile+numOrgs*groupsPerOrg+numGroups*usersPerGroup)
75+
76+
for fileID := 0; fileID < numFiles; fileID++ {
77+
step := fileID + 1
78+
for i := 0; i < orgsPerFile; i++ {
79+
relationships = append(relationships, tuple.MustParse(
80+
fmt.Sprintf("file:file%d#org@org:org%d", fileID, (i*step)%numOrgs),
81+
))
82+
}
83+
}
84+
85+
for orgID := 0; orgID < numOrgs; orgID++ {
86+
step := orgID + 1
87+
for i := 0; i < groupsPerOrg; i++ {
88+
relationships = append(relationships, tuple.MustParse(
89+
fmt.Sprintf("org:org%d#group@group:group%d", orgID, (i*step)%numGroups),
90+
))
91+
}
92+
}
93+
94+
for groupID := 0; groupID < numGroups; groupID++ {
95+
step := groupID + 1
96+
for i := 0; i < usersPerGroup; i++ {
97+
relationships = append(relationships, tuple.MustParse(
98+
fmt.Sprintf("group:group%d#member@user:user%d", groupID, (i*step)%numUsers),
99+
))
100+
}
101+
}
102+
103+
revision, err := common.WriteRelationships(ctx, rawDS, tuple.UpdateOperationCreate, relationships...)
104+
require.NoError(t, err)
105+
106+
dsSchema, err := schema.BuildSchemaFromDefinitions(compiled.ObjectDefinitions, nil)
107+
require.NoError(t, err)
108+
109+
canonicalOutline, err := BuildOutlineFromSchema(dsSchema, "file", "viewer")
110+
require.NoError(t, err)
111+
112+
resources := NewObjects("file", "file0")
113+
subject := NewObject("user", "user42").WithEllipses()
114+
115+
reader := NewQueryDatastoreReader(datalayer.NewDataLayer(rawDS).SnapshotReader(revision))
116+
117+
// ---- plain (LTR) ----
118+
119+
plainTrace := NewTraceLogger()
120+
plainIt, err := canonicalOutline.Compile()
121+
require.NoError(t, err)
122+
123+
plainSeq, err := NewLocalContext(ctx, WithReader(reader), WithTraceLogger(plainTrace)).
124+
Check(plainIt, resources, subject)
125+
require.NoError(t, err)
126+
plainPaths, err := CollectAll(plainSeq)
127+
require.NoError(t, err)
128+
require.NotEmpty(t, plainPaths, "plain plan must find paths from file0 to user42")
129+
130+
// ---- advised (CountAdvisor applied after a warm-up run) ----
131+
132+
obs := NewCountObserver()
133+
warmIt, err := canonicalOutline.Compile()
134+
require.NoError(t, err)
135+
warmSeq, err := NewLocalContext(ctx, WithReader(reader), WithObserver(obs)).
136+
Check(warmIt, resources, subject)
137+
require.NoError(t, err)
138+
_, err = CollectAll(warmSeq)
139+
require.NoError(t, err)
140+
141+
advisedCO, err := ApplyAdvisor(canonicalOutline, NewCountAdvisor(obs.GetStats()))
142+
require.NoError(t, err)
143+
advisedIt, err := advisedCO.Compile()
144+
require.NoError(t, err)
145+
146+
advisedTrace := NewTraceLogger()
147+
advisedSeq, err := NewLocalContext(ctx, WithReader(reader), WithTraceLogger(advisedTrace)).
148+
Check(advisedIt, resources, subject)
149+
require.NoError(t, err)
150+
advisedPaths, err := CollectAll(advisedSeq)
151+
require.NoError(t, err)
152+
153+
t.Logf("plain explain:\n%s\nplain trace:\n%s", plainIt.Explain(), plainTrace.DumpTrace())
154+
t.Logf("advised explain:\n%s\nadvised trace:\n%s", advisedIt.Explain(), advisedTrace.DumpTrace())
155+
156+
require.Equal(t, plainPaths, advisedPaths,
157+
"advised plan must return the same paths as plain",
158+
)
159+
}

pkg/query/datastore.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -450,9 +450,15 @@ func (r *DatastoreIterator) IterResourcesImpl(ctx *Context, subject ObjectAndRel
450450
}
451451

452452
// Check if subject relation matches what this iterator expects.
453-
// Both the schema's expected subrelation and the query's subject relation must match exactly.
454-
// Ellipsis is a specific relation value, not a wildcard.
455-
if r.base.Subrelation() != subject.Relation {
453+
// When the schema uses ellipsis ("..."), accept both "" and "..." from the caller.
454+
// Ideally callers always pass tuple.Ellipsis, but defensive tolerance here avoids
455+
// silent misses if a future caller constructs an ObjectAndRelation with Relation: "".
456+
expectedSubrelation := r.base.Subrelation()
457+
if expectedSubrelation == tuple.Ellipsis {
458+
if subject.Relation != "" && subject.Relation != tuple.Ellipsis {
459+
return EmptyPathSeq(), nil
460+
}
461+
} else if expectedSubrelation != subject.Relation {
456462
return EmptyPathSeq(), nil
457463
}
458464

@@ -712,12 +718,12 @@ func (r *DatastoreIterator) SubjectTypes() ([]ObjectType, error) {
712718
}}, nil
713719
}
714720

715-
// For ellipsis, return the base type with empty subrelation
716-
// Ellipsis means "any relation on this type"
721+
// For ellipsis, preserve the ellipsis subrelation so callers that construct
722+
// ObjectAndRelation values from SubjectTypes get the correct relation to query with.
717723
if r.base.Subrelation() == tuple.Ellipsis {
718724
return []ObjectType{{
719725
Type: r.base.Type(),
720-
Subrelation: "",
726+
Subrelation: tuple.Ellipsis,
721727
}}, nil
722728
}
723729

pkg/query/datastore_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,6 @@ func TestDatastoreIterator_Types(t *testing.T) {
414414
require.NoError(err)
415415
require.Len(subjectTypes, 1)
416416
require.Equal("user", subjectTypes[0].Type)
417-
require.Empty(subjectTypes[0].Subrelation) // Ellipsis returns empty subrelation
417+
require.Equal(tuple.Ellipsis, subjectTypes[0].Subrelation) // Ellipsis is preserved as-is
418418
})
419419
}

0 commit comments

Comments
 (0)