内部実装
コミット
4bb1d7b3のソースを読んだもの。ここでの主張はすべてファイルと行を指すこと。
コードマップ
| パス | 責務 |
|---|---|
cmd/spicedb/main.go | バイナリのエントリポイント。cobra コマンドツリーを構築し、serve が gRPC/HTTP を起動。 |
internal/services/v1/ | gRPC API v1: Permissions、Schema、Watch、Relationships。 |
internal/dispatch/ | ディスパッチチェーン: caching/、singleflight/、remote/、combined/、graph/。 |
internal/graph/ | グラフ探索: check.go、expand.go、lookupresources*.go、lookupsubjects.go。 |
internal/datastore/、pkg/datastore/ | ストレージ抽象とドライバ (crdb、postgres、mysql、spanner、memdb、proxy)。 |
pkg/schema/、pkg/schemadsl/ | スキーマ DSL (lexer、parser、compiler、generator) とスキーマモデル。 |
pkg/tuple/ | 関係タプルと中核データ構造。 |
pkg/caveats/、internal/caveats/ | CEL ベースの Caveats。 |
pkg/zedtoken/ | ZedToken (consistency トークン) のエンコード/デコード。 |
中核データ構造
ObjectAndRelation (ONR) がグラフのノードである。pkg/tuple/structs.go:24 で 3 つの文字列として定義される:
type ObjectAndRelation struct {
ObjectID string
ObjectType string
Relation string
}文字列表記は type:id#relation (pkg/tuple/structs.go:59)。
Relationship (pkg/tuple/structs.go:80) が SpiceDB の関係タプルである。RelationshipReference (resource ONR と subject ONR、structs.go:70) を埋め込み、OptionalCaveat、OptionalExpiration、OptionalIntegrity を持つ。ToCoreTuple (structs.go:89) がエンジンに渡す protobuf 形式へ変換する。
ディスパッチのホップ間では、リクエストは ResolverMeta (protobuf、internal/graph/computed/computecheck.go:128 で構築) に乗って運ばれ、AtRevision、DepthRemaining、TraversalBloom、SchemaHash を運ぶ。
ストレージの契約は pkg/datastore/datastore.go の datastore.Datastore インターフェースである: SnapshotReader(Revision) (:707)、OptimizedRevision (:711)、HeadRevision (:715)、Watch(...) (:739)、ReadWriteTx(...) (:768)。すべての読み取りがリビジョンに固定される MVCC 的モデルである。ZedToken (pkg/zedtoken/zedtoken.go、NewFromRevision は :71) はデータストアのリビジョンとスキーマハッシュを不透明トークンにエンコードし、クライアントがそれを渡して最小鮮度を要求する。
追う価値のあるパス
面白い分岐はディスパッチのループ保護である。SpiceDB のスキーマは自己参照的・循環的な relation を表現できる (view 権限が parent->view を辿るフォルダなど) ため、ディスパッチャは無限再帰しうるグラフを安全に辿らねばならない。
チェックの入口で、最大深さに合わせた traversal bloom filter を internal/graph/computed/computecheck.go:113 で生成する:
bf, err := v1.NewTraversalBloomFilter(uint(params.MaximumDepth))このフィルタはディスパッチされる全リクエストのメタデータに乗る (computecheck.go:131、TraversalBloom: bf)。singleflight ディスパッチャは各ディスパッチキーをそのフィルタに記録し、既出かどうかを 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)
}キーが再出に見えるとき、singleflight はそれを束ねず、delegate へ直接流す。理由は singleflight.go:70-73 のコメントにある: 再帰呼び出しを singleflight で束ねるとデッドロックする。実行中の親が子の完了を待っているからだ。bloom フィルタの偽陽性は余分なディスパッチ 1 回で済み、トレードオフとして安い側になる。
読んで驚いた点
traversal bloom filter は生成コードではなく手書きである。pkg/proto/dispatch/v1/02_resolvermeta.go は冒頭に // This file is *not* autogenerated. (:1) と書かれ、RecordTraversal はそこの :15 に実装される: リクエストから bloom を取り出し、キーで TestString を実行し、既出なら possiblyLoop=true を返し、未出なら AddString して再マーシャルする。デフォルト偽陽性率は 0.001、すなわち 0.1% (02_resolvermeta.go:41)。
singleflight には防御的フォールバックもある: 空の TraversalBloom を持つリクエストが来た場合 (この機能より前のバージョンの呼び出し元)、ディスパッチャはサイズ 50 の新しいフィルタを作り、そのリクエストでは無限再帰を避けるため singleflight を完全にスキップする (internal/dispatch/singleflight/singleflight.go:58-68)。ループ安全性はスキーマでもグラフ探索でもなく、ディスパッチレイヤに宿っている。