Internals
Read from the source at commit
4bb1d7b3. Every claim here should point at a file and line.
Code map
| Path | Responsibility |
|---|---|
cmd/spicedb/main.go | Binary entry point: builds the cobra command tree; serve starts gRPC/HTTP. |
internal/services/v1/ | gRPC API v1: Permissions, Schema, Watch, Relationships. |
internal/dispatch/ | Dispatch chain: caching/, singleflight/, remote/, combined/, graph/. |
internal/graph/ | Graph traversal: check.go, expand.go, lookupresources*.go, lookupsubjects.go. |
internal/datastore/, pkg/datastore/ | Storage abstraction and drivers (crdb, postgres, mysql, spanner, memdb, proxy). |
pkg/schema/, pkg/schemadsl/ | Schema DSL (lexer, parser, compiler, generator) and schema model. |
pkg/tuple/ | Relationship tuples and core data structures. |
pkg/caveats/, internal/caveats/ | CEL-based Caveats. |
pkg/zedtoken/ | ZedToken consistency-token encode/decode. |
Core data structures
ObjectAndRelation (ONR) is the graph node. It is defined at pkg/tuple/structs.go:24 as three strings:
type ObjectAndRelation struct {
ObjectID string
ObjectType string
Relation string
}Its string form is type:id#relation (pkg/tuple/structs.go:59).
Relationship (pkg/tuple/structs.go:80) is SpiceDB's relation tuple. It embeds a RelationshipReference (resource ONR plus subject ONR, structs.go:70) and carries OptionalCaveat, OptionalExpiration, and OptionalIntegrity. ToCoreTuple (structs.go:89) converts it to the protobuf form passed through the engine.
Between dispatch hops the request travels in a ResolverMeta (protobuf, populated at internal/graph/computed/computecheck.go:128) carrying AtRevision, DepthRemaining, TraversalBloom, and SchemaHash.
The storage contract is the datastore.Datastore interface in pkg/datastore/datastore.go: SnapshotReader(Revision) (:707), OptimizedRevision (:711), HeadRevision (:715), Watch(...) (:739), and ReadWriteTx(...) (:768). It is an MVCC-style model where every read is pinned to a revision. ZedToken (pkg/zedtoken/zedtoken.go, NewFromRevision at :71) encodes a datastore revision plus schema hash into an opaque token clients hand back to demand a minimum freshness.
A path worth tracing
The interesting branch is loop protection in dispatch. SpiceDB schemas can express self-referential or cyclic relations (a folder whose view permission walks parent->view), so the dispatcher must traverse a graph that could recurse forever.
The check entry builds a traversal bloom filter sized to the maximum depth at internal/graph/computed/computecheck.go:113:
bf, err := v1.NewTraversalBloomFilter(uint(params.MaximumDepth))The filter rides along in every dispatched request's metadata (computecheck.go:131, TraversalBloom: bf). The singleflight dispatcher records each dispatch key into that filter and checks whether it was seen before, at internal/dispatch/singleflight/singleflight.go:74:
possiblyLoop, err := req.Metadata.RecordTraversal(keyString)
if err != nil {
return &v1.DispatchCheckResponse{Metadata: &v1.ResponseMeta{DispatchCount: 1}}, err
} else if possiblyLoop {
log.Debug().Object("DispatchCheckRequest", req).Str("key", keyString).Msg("potential DispatchCheckRequest loop detected")
singleFlightCount.WithLabelValues("DispatchCheck", "loop").Inc()
return d.delegate.DispatchCheck(ctx, req)
}When a key looks like a repeat, singleflight does not coalesce it; it passes it straight to the delegate. The comment at singleflight.go:70-73 explains why: coalescing a recursive call under singleflight would deadlock, because the in-flight parent is itself waiting on the child. A bloom-filter false positive only costs one extra dispatch, which is the cheaper side of the trade-off.
Things that surprised me
The traversal bloom filter is hand-written, not generated. pkg/proto/dispatch/v1/02_resolvermeta.go opens with // This file is *not* autogenerated. (:1) and RecordTraversal is implemented there at :15: it unmarshals the bloom from the request, runs TestString on the key, returns possiblyLoop=true if present, otherwise AddString and re-marshals. The default false-positive rate is 0.001, that is 0.1% (02_resolvermeta.go:41).
There is also a defensive fallback in singleflight: if a request arrives with an empty TraversalBloom (an older caller that predates the feature), the dispatcher creates a fresh filter of size 50 and skips singleflight entirely for that request rather than risk an un-bounded recursion (internal/dispatch/singleflight/singleflight.go:58-68). Loop safety lives in the dispatch layer, not in the schema or the graph walker.