Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,6 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
resenje.org/singleflight v0.4.3 // indirect
sigs.k8s.io/controller-runtime v0.22.4 // indirect
)
9 changes: 4 additions & 5 deletions internal/datastore/proxy/schemacaching/standardcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"sync"
"unsafe"

"golang.org/x/sync/singleflight"
"resenje.org/singleflight"

"github.com/authzed/spicedb/pkg/cache"
"github.com/authzed/spicedb/pkg/datastore"
Expand All @@ -20,7 +20,7 @@ import (
type definitionCachingProxy struct {
datastore.Datastore
c cache.Cache[cache.StringKey, *cacheEntry]
readGroup singleflight.Group
readGroup singleflight.Group[string, *cacheEntry]
}

func (p *definitionCachingProxy) Close() error {
Expand Down Expand Up @@ -176,8 +176,7 @@ func readAndCache[T schemaDefinition](
loaded, found := r.p.c.Get(cache.StringKey(cacheRevisionKey))
if !found {
// We couldn't use the cached entry, load one
var err error
loadedRaw, err, _ := r.p.readGroup.Do(cacheRevisionKey, func() (any, error) {
loadedEntry, _, err := r.p.readGroup.Do(ctx, cacheRevisionKey, func(ctx context.Context) (*cacheEntry, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this and the other callsite: we're now passing the context which means it can be cancelled, but do we know that both of these codepaths have timeouts on them somewhere further up the chain?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm wondering why we didn't act defensively and added a context timeout anyway..

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm wondering why we didn't act defensively and added a context timeout anyway..

That was my proposal: we wrap this (and any other singleflight) calls in a timeout anyway, but since we didn't get consensus, I did this portion first

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but do we know that both of these codepaths have timeouts on them somewhere further up the chain?

Yes, all requests have timeouts ultimately from the API layer that moves downward into here (except for writes, which break the context, but have their own timeouts)

// sever the context so that another branch doesn't cancel the
// single-flighted read
loaded, updatedRev, err := reader(context.WithoutCancel(ctx), name)
Expand All @@ -199,7 +198,7 @@ func readAndCache[T schemaDefinition](
return *new(T), datastore.NoRevision, err
}

loaded = loadedRaw.(*cacheEntry)
loaded = loadedEntry
}

return loaded.definition.(T), loaded.updated, loaded.notFound
Expand Down
10 changes: 5 additions & 5 deletions internal/datastore/revisions/optimized.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/benbjohnson/clock"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/singleflight"
"resenje.org/singleflight"

log "github.com/authzed/spicedb/internal/logging"
"github.com/authzed/spicedb/internal/telemetry/otelconv"
Expand Down Expand Up @@ -63,12 +63,12 @@ func (cor *CachedOptimizedRevisions) OptimizedRevision(ctx context.Context) (dat
}
cor.RUnlock()

newQuantizedRevision, err, _ := cor.updateGroup.Do("", func() (any, error) {
newQuantizedRevision, _, err := cor.updateGroup.Do(ctx, "", func(ctx context.Context) (datastore.Revision, error) {
log.Ctx(ctx).Debug().Time("now", localNow).Msg("computing new revision")

optimized, validFor, err := cor.optimizedFunc(ctx)
if err != nil {
return nil, fmt.Errorf("unable to compute optimized revision: %w", err)
return datastore.NoRevision, fmt.Errorf("unable to compute optimized revision: %w", err)
}

rvt := localNow.Add(validFor)
Expand All @@ -95,7 +95,7 @@ func (cor *CachedOptimizedRevisions) OptimizedRevision(ctx context.Context) (dat
if err != nil {
return datastore.NoRevision, err
}
return newQuantizedRevision.(datastore.Revision), err
return newQuantizedRevision, err
}

// CachedOptimizedRevisions does caching and deduplication for requests for optimized revisions.
Expand All @@ -110,7 +110,7 @@ type CachedOptimizedRevisions struct {
candidates []validRevision // GUARDED_BY(RWMutex)

// the updategroup consolidates concurrent requests to the database into 1
updateGroup singleflight.Group
updateGroup singleflight.Group[string, datastore.Revision]
}

type validRevision struct {
Expand Down
1 change: 1 addition & 0 deletions magefiles/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func (Lint) Analyzers() error {
"-protomarshalcheck",
"-telemetryconvcheck",
"-iferrafterrowclosecheck",
"-singleflightcheck",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use depguard please

settings:
    depguard:
      rules:
        main:
          deny:
            - pkg: "golang.org/x/sync/singleflight"
              desc: "use resenje.org/singleflight instead so that we can break out of deadlocks"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the idea was that there may be places where you want to singleflight and don't have access to a context object. Unless we're saying that we'd want to create a context with timeout at that callsite?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't use depguard: there are times when we need to use singleflight without context being available; the linter only checks the cases where context is available

// Skip generated protobuf files for this check
// Also skip test where we're explicitly using proto.Marshal to assert
// that the proto.Marshal behavior matches foo.MarshalVT()
Expand Down
2 changes: 2 additions & 0 deletions tools/analyzers/cmd/analyzers/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/authzed/spicedb/tools/analyzers/nilvaluecheck"
"github.com/authzed/spicedb/tools/analyzers/paniccheck"
"github.com/authzed/spicedb/tools/analyzers/protomarshalcheck"
"github.com/authzed/spicedb/tools/analyzers/singleflightcheck"
"github.com/authzed/spicedb/tools/analyzers/telemetryconvcheck"
"github.com/authzed/spicedb/tools/analyzers/zerologmarshalcheck"
)
Expand All @@ -28,6 +29,7 @@ func main() {
lendowncastcheck.Analyzer(),
protomarshalcheck.Analyzer(),
zerologmarshalcheck.Analyzer(),
singleflightcheck.Analyzer(),
telemetryconvcheck.Analyzer(),
)
}
5 changes: 5 additions & 0 deletions tools/analyzers/go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.31.0-2023080216373
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.4-20250130201111-63bb56e20495.1 h1:4erM3WLgEG/HIBrpBDmRbs1puhd7p0z7kNXDuhHthwM=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.4-20250130201111-63bb56e20495.1/go.mod h1:novQBstnxcGpfKf8qGRATqn1anQKwMJIbH5Q581jibU=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
buf.build/go/hyperpb v0.1.3/go.mod h1:IHXAM5qnS0/Fsnd7/HGDghFNvUET646WoHmq1FDZXIE=
buf.build/go/protovalidate v0.12.0 h1:4GKJotbspQjRCcqZMGVSuC8SjwZ/FmgtSuKDpKUTZew=
buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0=
cloud.google.com/go/accessapproval v1.7.1 h1:/5YjNhR6lzCvmJZAnByYkfEgWjfAKwYP6nkuTk6nKFE=
Expand Down Expand Up @@ -2545,6 +2546,7 @@ github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/q
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
Expand All @@ -2567,6 +2569,7 @@ github.com/cyphar/filepath-securejoin v0.3.5 h1:L81NHjquoQmcPgXcttUS9qTSR/+bXry6
github.com/cyphar/filepath-securejoin v0.3.5/go.mod h1:edhVd3c6OXKjUmSrVa/tGJRS9joFTxlslFCAyaxigkE=
github.com/cyphar/filepath-securejoin v0.5.1 h1:eYgfMq5yryL4fbWfkLpFFy2ukSELzaJOTaUTuh+oF48=
github.com/cyphar/filepath-securejoin v0.5.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/docker/docker v28.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
Expand Down Expand Up @@ -2850,6 +2853,7 @@ github.com/sagikazarmark/crypt v0.15.0 h1:TQJg76CemcIdJyC9/dmNjU9OUyIFHyvE50Tpq1
github.com/sagikazarmark/crypt v0.15.0/go.mod h1:5rwNNax6Mlk9sZ40AcyVtiEw24Z4J04cfSioF2COKmc=
github.com/sagikazarmark/crypt v0.17.0 h1:ZA/7pXyjkHoK4bW4mIdnCLvL8hd+Nrbiw7Dqk7D4qUk=
github.com/sagikazarmark/crypt v0.17.0/go.mod h1:SMtHTvdmsZMuY/bpZoqokSoChIrcJ/epOxZN58PbZDg=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646 h1:RpforrEYXWkmGwJHIGnLZ3tTWStkjVVstwzNGqxX2Ds=
github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
github.com/seccomp/libseccomp-golang v0.10.0 h1:aA4bp+/Zzi0BnWZ2F1wgNBs5gTpm+na2rWM6M9YjLpY=
Expand Down Expand Up @@ -2908,6 +2912,7 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/timandy/routine v1.1.6/go.mod h1:kXslgIosdY8LW0byTyPnenDgn4/azt2euufAq9rK51w=
github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM=
github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
Expand Down
131 changes: 131 additions & 0 deletions tools/analyzers/singleflightcheck/singleflightcheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package singleflightcheck

import (
"flag"
"fmt"
"go/ast"
"regexp"
"strings"

"github.com/samber/lo"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
)

func Analyzer() *analysis.Analyzer {
flagSet := flag.NewFlagSet("singleflightcheck", flag.ExitOnError)
skipPkg := flagSet.String("skip-pkg", "", "package(s) to skip for linting")
skipFiles := flagSet.String("skip-files", "", "patterns of files to skip for linting")

return &analysis.Analyzer{
Name: "singleflightcheck",
Doc: "reports uses of golang.org/x/sync/singleflight.Group.Do in functions that have a context.Context parameter; use resenje.org/singleflight instead",
Run: func(pass *analysis.Pass) (any, error) {
// Check for a skipped package.
if len(*skipPkg) > 0 {
skipped := lo.Map(strings.Split(*skipPkg, ","), func(skipped string, _ int) string { return strings.TrimSpace(skipped) })
for _, s := range skipped {
if strings.Contains(pass.Pkg.Path(), s) {
return nil, nil
}
}
}

// Check for a skipped file.
skipFilePatterns := make([]string, 0)
if len(*skipFiles) > 0 {
skipFilePatterns = lo.Map(strings.Split(*skipFiles, ","), func(skipped string, _ int) string { return strings.TrimSpace(skipped) })
}
for _, pattern := range skipFilePatterns {
_, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid skip-files pattern `%s`: %w", pattern, err)
}
}

inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

nodeFilter := []ast.Node{
(*ast.File)(nil),
(*ast.CallExpr)(nil),
}

inspect.WithStack(nodeFilter, func(n ast.Node, push bool, stack []ast.Node) bool {
switch s := n.(type) {
case *ast.File:
for _, pattern := range skipFilePatterns {
isMatch, _ := regexp.MatchString(pattern, pass.Fset.Position(s.Package).Filename)
if isMatch {
return false
}
}
return true

case *ast.CallExpr:
// Check if this is a call to .Do on a selector expression.
selector, ok := s.Fun.(*ast.SelectorExpr)
if !ok || selector.Sel.Name != "Do" {
return true
}

// Check that the receiver type is golang.org/x/sync/singleflight.Group.
receiverType := pass.TypesInfo.TypeOf(selector.X)
if receiverType == nil {
return true
}

typeStr := receiverType.String()
if !strings.Contains(typeStr, "golang.org/x/sync/singleflight") {
return true
}

// Check if the enclosing function has a context.Context parameter.
if !enclosingFuncHasContext(stack) {
return true
}

pass.Reportf(n.Pos(), "In package %s: use resenje.org/singleflight instead of golang.org/x/sync/singleflight in functions with context.Context", pass.Pkg.Path())
return false

default:
return true
}
})

return nil, nil
},
Requires: []*analysis.Analyzer{inspect.Analyzer},
Flags: *flagSet,
}
}

func enclosingFuncHasContext(stack []ast.Node) bool {
for i := len(stack) - 1; i >= 0; i-- {
var params *ast.FieldList
switch f := stack[i].(type) {
case *ast.FuncDecl:
params = f.Type.Params
case *ast.FuncLit:
params = f.Type.Params
default:
continue
}

if params == nil {
return false
}

for _, param := range params.List {
if sel, ok := param.Type.(*ast.SelectorExpr); ok {
if ident, ok := sel.X.(*ast.Ident); ok {
if ident.Name == "context" && sel.Sel.Name == "Context" {
return true
}
}
}
}
return false
}
return false
}
15 changes: 15 additions & 0 deletions tools/analyzers/singleflightcheck/singleflightcheck_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package singleflightcheck

import (
"testing"

"golang.org/x/tools/go/analysis/analysistest"
)

func TestAnalyzer(t *testing.T) {
analyzer := Analyzer()

testdata := analysistest.TestData()
analysistest.Run(t, testdata, analyzer, "badsingleflight")
analysistest.Run(t, testdata, analyzer, "goodsingleflight")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package badsingleflight

import (
"context"

"golang.org/x/sync/singleflight"
)

var group singleflight.Group

func HasContext(ctx context.Context) {
group.Do("key", func() (any, error) { // want "use resenje.org/singleflight instead of golang.org/x/sync/singleflight in functions with context.Context"
return nil, nil
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package singleflight

type Group struct{}

func (g *Group) Do(key string, fn func() (any, error)) (any, error, bool) {
v, err := fn()
return v, err, false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package goodsingleflight

import (
"golang.org/x/sync/singleflight"
)

var group singleflight.Group

func NoContext() {
group.Do("key", func() (any, error) {
return nil, nil
})
}
Loading