Skip to content

Commit 181d295

Browse files
committed
feat(graph): add per-node timing and datastore-read attribution to check debug trace
Adds start_time and datastore_queries to the internal dispatch CheckDebugTrace so a single Check / CheckBulk debug trace can answer two questions that duration alone cannot: (1) whether sub-problems were traversed concurrently, and (2) where time was spent blocked on datastore reads. start_time is the wall-clock anchor for each node (the monotonic duration stays authoritative; end = start_time + duration). A context-scoped, concurrency-safe collector is installed per dispatch node; the observable datastore proxy records each relationship query (shape, start, duration, row count) into it, so reads attribute to the node that issued them. Both only activate when debug tracing is enabled, leaving the non-debug hot path untouched. This is the internal-proto-only slice; the public authzed/api CheckDebugTrace fields and the internal->public conversion follow once that field lands. Refs #3198
1 parent 2ff0a38 commit 181d295

9 files changed

Lines changed: 1009 additions & 101 deletions

File tree

internal/datastore/proxy/observable.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package proxy
22

33
import (
44
"context"
5+
"time"
56

67
"github.com/prometheus/client_golang/prometheus"
78
"github.com/prometheus/client_golang/prometheus/promauto"
@@ -12,6 +13,7 @@ import (
1213
v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
1314

1415
"github.com/authzed/spicedb/internal/datastore/common"
16+
"github.com/authzed/spicedb/internal/dstrace"
1517
"github.com/authzed/spicedb/internal/telemetry/otelconv"
1618
"github.com/authzed/spicedb/pkg/datastore"
1719
"github.com/authzed/spicedb/pkg/datastore/options"
@@ -230,12 +232,19 @@ func (r *observableReader) LegacyReadNamespaceByName(ctx context.Context, nsName
230232

231233
func (r *observableReader) QueryRelationships(ctx context.Context, filter datastore.RelationshipsFilter, opts ...options.QueryOptionsOption) (datastore.RelationshipIterator, error) {
232234
queryOpts := options.NewQueryOptionsWithOptions(opts...)
233-
ctx, closer := observe(ctx, "QueryRelationships", string(queryOpts.QueryShape), trace.WithAttributes(
235+
queryShape := string(queryOpts.QueryShape)
236+
ctx, closer := observe(ctx, "QueryRelationships", queryShape, trace.WithAttributes(
234237
attribute.String(otelconv.AttrDatastoreResourceType, filter.OptionalResourceType),
235238
attribute.String(otelconv.AttrDatastoreResourceRelation, filter.OptionalResourceRelation),
236-
attribute.String(otelconv.AttrDatastoreQueryShape, string(queryOpts.QueryShape)),
239+
attribute.String(otelconv.AttrDatastoreQueryShape, queryShape),
237240
))
238241

242+
collector := dstrace.CollectorFromContext(ctx)
243+
var start time.Time
244+
if collector != nil {
245+
start = time.Now()
246+
}
247+
239248
iterator, err := r.delegate.QueryRelationships(ctx, filter, opts...)
240249
if err != nil {
241250
closer()
@@ -253,14 +262,24 @@ func (r *observableReader) QueryRelationships(ctx context.Context, filter datast
253262
}
254263
}
255264
loadedRelationshipCount.Observe(float64(count))
265+
if collector != nil {
266+
collector.Record(queryShape, start, time.Since(start), count)
267+
}
256268
}, nil
257269
}
258270

259271
func (r *observableReader) ReverseQueryRelationships(ctx context.Context, subjectsFilter datastore.SubjectsFilter, opts ...options.ReverseQueryOptionsOption) (datastore.RelationshipIterator, error) {
260272
queryOpts := options.NewReverseQueryOptionsWithOptions(opts...)
261-
ctx, closer := observe(ctx, "ReverseQueryRelationships", string(queryOpts.QueryShapeForReverse), trace.WithAttributes(
273+
queryShape := string(queryOpts.QueryShapeForReverse)
274+
ctx, closer := observe(ctx, "ReverseQueryRelationships", queryShape, trace.WithAttributes(
262275
attribute.String(otelconv.AttrDatastoreSubjectType, subjectsFilter.SubjectType),
263-
attribute.String(otelconv.AttrDatastoreQueryShape, string(queryOpts.QueryShapeForReverse))))
276+
attribute.String(otelconv.AttrDatastoreQueryShape, queryShape)))
277+
278+
collector := dstrace.CollectorFromContext(ctx)
279+
var start time.Time
280+
if collector != nil {
281+
start = time.Now()
282+
}
264283

265284
iterator, err := r.delegate.ReverseQueryRelationships(ctx, subjectsFilter, opts...)
266285
if err != nil {
@@ -279,6 +298,9 @@ func (r *observableReader) ReverseQueryRelationships(ctx context.Context, subjec
279298
}
280299
}
281300
loadedRelationshipCount.Observe(float64(count))
301+
if collector != nil {
302+
collector.Record(queryShape, start, time.Since(start), count)
303+
}
282304
}, nil
283305
}
284306

internal/datastore/proxy/observable_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import (
1313

1414
"github.com/authzed/spicedb/internal/datastore/proxy/proxy_test"
1515
"github.com/authzed/spicedb/internal/datastore/revisions"
16+
"github.com/authzed/spicedb/internal/dstrace"
1617
"github.com/authzed/spicedb/pkg/datastore"
18+
"github.com/authzed/spicedb/pkg/datastore/options"
19+
"github.com/authzed/spicedb/pkg/datastore/queryshape"
1720
core "github.com/authzed/spicedb/pkg/proto/core/v1"
1821
"github.com/authzed/spicedb/pkg/tuple"
1922
)
@@ -360,6 +363,90 @@ func TestObservableProxy_ReaderMethodsWithMetrics(t *testing.T) {
360363
}
361364
}
362365

366+
func TestObservableProxy_RecordsDatastoreQueriesToCollector(t *testing.T) {
367+
t.Run("QueryRelationships", func(t *testing.T) {
368+
dsMock, readerMock, _ := newMocks()
369+
twoRelIter := datastore.RelationshipIterator(func(yield func(tuple.Relationship, error) bool) {
370+
if !yield(tuple.MustParse("document:1#viewer@user:1"), nil) {
371+
return
372+
}
373+
yield(tuple.MustParse("document:1#viewer@user:2"), nil)
374+
})
375+
readerMock.On("QueryRelationships", datastore.RelationshipsFilter{OptionalResourceType: "document"}, mock.Anything).
376+
Return(twoRelIter, nil).Once()
377+
378+
sut := NewObservableDatastoreProxy(dsMock)
379+
ctx, collector := dstrace.WithCollector(t.Context())
380+
381+
iter, err := sut.SnapshotReader(testRev).QueryRelationships(
382+
ctx,
383+
datastore.RelationshipsFilter{OptionalResourceType: "document"},
384+
options.WithQueryShape(queryshape.CheckPermissionSelectDirectSubjects),
385+
)
386+
require.NoError(t, err)
387+
for range iter {
388+
}
389+
390+
queries := collector.Queries()
391+
require.Len(t, queries, 1)
392+
require.Equal(t, string(queryshape.CheckPermissionSelectDirectSubjects), queries[0].QueryShape)
393+
require.Equal(t, uint64(2), queries[0].RelationshipCount)
394+
require.NotNil(t, queries[0].StartTime)
395+
require.NotNil(t, queries[0].Duration)
396+
397+
dsMock.AssertExpectations(t)
398+
readerMock.AssertExpectations(t)
399+
})
400+
401+
t.Run("ReverseQueryRelationships", func(t *testing.T) {
402+
dsMock, readerMock, _ := newMocks()
403+
oneRelIter := datastore.RelationshipIterator(func(yield func(tuple.Relationship, error) bool) {
404+
yield(tuple.MustParse("document:1#viewer@user:1"), nil)
405+
})
406+
readerMock.On("ReverseQueryRelationships", datastore.SubjectsFilter{SubjectType: "user"}, mock.Anything).
407+
Return(oneRelIter, nil).Once()
408+
409+
sut := NewObservableDatastoreProxy(dsMock)
410+
ctx, collector := dstrace.WithCollector(t.Context())
411+
412+
iter, err := sut.SnapshotReader(testRev).ReverseQueryRelationships(
413+
ctx,
414+
datastore.SubjectsFilter{SubjectType: "user"},
415+
options.WithQueryShapeForReverse(queryshape.MatchingResourcesForSubject),
416+
)
417+
require.NoError(t, err)
418+
for range iter {
419+
}
420+
421+
queries := collector.Queries()
422+
require.Len(t, queries, 1)
423+
require.Equal(t, string(queryshape.MatchingResourcesForSubject), queries[0].QueryShape)
424+
require.Equal(t, uint64(1), queries[0].RelationshipCount)
425+
426+
dsMock.AssertExpectations(t)
427+
readerMock.AssertExpectations(t)
428+
})
429+
430+
t.Run("no collector is a no-op", func(t *testing.T) {
431+
dsMock, readerMock, _ := newMocks()
432+
iter := datastore.RelationshipIterator(func(yield func(tuple.Relationship, error) bool) {
433+
yield(tuple.MustParse("document:1#viewer@user:1"), nil)
434+
})
435+
readerMock.On("QueryRelationships", datastore.RelationshipsFilter{OptionalResourceType: "document"}).
436+
Return(iter, nil).Once()
437+
438+
sut := NewObservableDatastoreProxy(dsMock)
439+
got, err := sut.SnapshotReader(testRev).QueryRelationships(
440+
t.Context(), datastore.RelationshipsFilter{OptionalResourceType: "document"})
441+
require.NoError(t, err)
442+
for range got {
443+
}
444+
445+
dsMock.AssertExpectations(t)
446+
readerMock.AssertExpectations(t)
447+
})
448+
}
449+
363450
func TestObservableProxy_RWTMethodsWithMetrics(t *testing.T) {
364451
tests := []struct {
365452
name string

internal/dstrace/collector.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Package dstrace provides a context-scoped collector for attributing datastore
2+
// query timings to a single Check dispatch node. It is populated by the
3+
// observable datastore proxy and drained into the check debug trace, but lives
4+
// in its own leaf package so neither side has to depend on the other.
5+
package dstrace
6+
7+
import (
8+
"context"
9+
"sync"
10+
"time"
11+
12+
"google.golang.org/protobuf/types/known/durationpb"
13+
"google.golang.org/protobuf/types/known/timestamppb"
14+
15+
v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
16+
)
17+
18+
type collectorCtxKey struct{}
19+
20+
// Collector accumulates datastore query observations for a single Check
21+
// dispatch node. It is safe for concurrent use: a node may issue multiple
22+
// relationship queries in parallel (e.g. the branches of a union).
23+
type Collector struct {
24+
mu sync.Mutex
25+
queries []*v1.DatastoreQuery
26+
}
27+
28+
// WithCollector returns a context carrying a fresh Collector, along with that
29+
// collector. It should be called once per dispatch node when debug tracing is
30+
// enabled so that the queries the node issues itself are attributed to it;
31+
// dispatched children re-install their own collector and therefore capture their
32+
// own queries.
33+
func WithCollector(ctx context.Context) (context.Context, *Collector) {
34+
c := &Collector{}
35+
return context.WithValue(ctx, collectorCtxKey{}, c), c
36+
}
37+
38+
// CollectorFromContext returns the Collector stored in ctx, or nil if none is
39+
// present (i.e. debug tracing is disabled). A nil Collector is safe to call
40+
// Record/Queries on.
41+
func CollectorFromContext(ctx context.Context) *Collector {
42+
c, _ := ctx.Value(collectorCtxKey{}).(*Collector)
43+
return c
44+
}
45+
46+
// Record appends a single datastore query observation. It is a no-op on a nil
47+
// Collector.
48+
func (c *Collector) Record(queryShape string, start time.Time, duration time.Duration, relationshipCount uint64) {
49+
if c == nil {
50+
return
51+
}
52+
c.mu.Lock()
53+
defer c.mu.Unlock()
54+
c.queries = append(c.queries, &v1.DatastoreQuery{
55+
QueryShape: queryShape,
56+
StartTime: timestamppb.New(start),
57+
Duration: durationpb.New(duration),
58+
RelationshipCount: relationshipCount,
59+
})
60+
}
61+
62+
// Queries returns the collected datastore query observations. It returns nil on
63+
// a nil Collector.
64+
func (c *Collector) Queries() []*v1.DatastoreQuery {
65+
if c == nil {
66+
return nil
67+
}
68+
c.mu.Lock()
69+
defer c.mu.Unlock()
70+
return c.queries
71+
}

internal/dstrace/collector_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package dstrace
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestCollectorFromContextAbsentIsNilSafe(t *testing.T) {
13+
c := CollectorFromContext(context.Background())
14+
require.Nil(t, c)
15+
16+
// A nil collector must be safe to use.
17+
c.Record("shape", time.Now(), time.Millisecond, 1)
18+
require.Nil(t, c.Queries())
19+
}
20+
21+
func TestWithCollectorRecordsQueries(t *testing.T) {
22+
ctx, c := WithCollector(context.Background())
23+
require.NotNil(t, c)
24+
require.Same(t, c, CollectorFromContext(ctx))
25+
26+
start := time.Now()
27+
c.Record("shape-a", start, 2*time.Millisecond, 3)
28+
c.Record("shape-b", start, 5*time.Millisecond, 0)
29+
30+
queries := c.Queries()
31+
require.Len(t, queries, 2)
32+
33+
require.Equal(t, "shape-a", queries[0].QueryShape)
34+
require.Equal(t, uint64(3), queries[0].RelationshipCount)
35+
require.Equal(t, 2*time.Millisecond, queries[0].Duration.AsDuration())
36+
require.WithinDuration(t, start, queries[0].StartTime.AsTime(), time.Microsecond)
37+
38+
require.Equal(t, "shape-b", queries[1].QueryShape)
39+
require.Equal(t, uint64(0), queries[1].RelationshipCount)
40+
}
41+
42+
func TestCollectorConcurrentRecord(t *testing.T) {
43+
_, c := WithCollector(context.Background())
44+
45+
const goroutines = 16
46+
const perGoroutine = 64
47+
48+
var wg sync.WaitGroup
49+
wg.Add(goroutines)
50+
for i := 0; i < goroutines; i++ {
51+
go func() {
52+
defer wg.Done()
53+
for j := 0; j < perGoroutine; j++ {
54+
c.Record("shape", time.Now(), time.Microsecond, 1)
55+
}
56+
}()
57+
}
58+
wg.Wait()
59+
60+
require.Len(t, c.Queries(), goroutines*perGoroutine)
61+
}
62+
63+
// Each child context gets its own collector, so a parent never sees a child's
64+
// recorded queries.
65+
func TestNestedCollectorsAreIndependent(t *testing.T) {
66+
parentCtx, parent := WithCollector(context.Background())
67+
parent.Record("parent-query", time.Now(), time.Millisecond, 1)
68+
69+
childCtx, child := WithCollector(parentCtx)
70+
child.Record("child-query", time.Now(), time.Millisecond, 2)
71+
72+
require.Len(t, parent.Queries(), 1)
73+
require.Equal(t, "parent-query", parent.Queries()[0].QueryShape)
74+
75+
require.Same(t, child, CollectorFromContext(childCtx))
76+
require.Len(t, child.Queries(), 1)
77+
require.Equal(t, "child-query", child.Queries()[0].QueryShape)
78+
}

internal/graph/check.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import (
1010
"go.opentelemetry.io/otel"
1111
"go.opentelemetry.io/otel/trace"
1212
"google.golang.org/protobuf/types/known/durationpb"
13+
"google.golang.org/protobuf/types/known/timestamppb"
1314

1415
"github.com/authzed/spicedb/internal/dispatch"
16+
"github.com/authzed/spicedb/internal/dstrace"
1517
"github.com/authzed/spicedb/internal/graph/hints"
1618
log "github.com/authzed/spicedb/internal/logging"
1719
"github.com/authzed/spicedb/internal/namespace"
@@ -98,9 +100,14 @@ type currentRequestContext struct {
98100
// Check performs a check request with the provided request and context
99101
func (cc *ConcurrentChecker) Check(ctx context.Context, req ValidatedCheckRequest, relation *core.Relation) (*v1.DispatchCheckResponse, error) {
100102
var startTime *time.Time
103+
var collector *dstrace.Collector
101104
if req.Debug != v1.DispatchCheckRequest_NO_DEBUG {
102105
now := time.Now()
103106
startTime = &now
107+
// Install a per-node collector so the datastore queries this node issues
108+
// itself are attributed to it. Dispatched children re-enter Check and
109+
// install their own collector, so their reads attribute to them.
110+
ctx, collector = dstrace.WithCollector(ctx)
104111
}
105112

106113
resolved := cc.checkInternal(ctx, req, relation)
@@ -131,6 +138,8 @@ func (cc *ConcurrentChecker) Check(ctx context.Context, req ValidatedCheckReques
131138

132139
debugInfo.Check.Request = clonedRequest
133140
debugInfo.Check.Duration = durationpb.New(time.Since(*startTime))
141+
debugInfo.Check.StartTime = timestamppb.New(*startTime)
142+
debugInfo.Check.DatastoreQueries = collector.Queries()
134143

135144
if nspkg.GetRelationKind(relation) == iv1.RelationMetadata_PERMISSION {
136145
debugInfo.Check.ResourceRelationType = v1.CheckDebugTrace_PERMISSION

0 commit comments

Comments
 (0)