Skip to content

Commit 8dcf4cc

Browse files
committed
shift to file-based cache, leveraging existing metas indexer
1 parent c63b183 commit 8dcf4cc

9 files changed

Lines changed: 686 additions & 338 deletions

File tree

internal/catalogd/graphql/graphql.go

Lines changed: 292 additions & 65 deletions
Large diffs are not rendered by default.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package graphql
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/graphql-go/graphql/language/ast"
7+
"github.com/graphql-go/graphql/language/parser"
8+
)
9+
10+
const (
11+
MaxQueryDepth = 10
12+
MaxQueryAliases = 50
13+
MaxQueryFields = 500
14+
)
15+
16+
type queryComplexity struct {
17+
aliases int
18+
fields int
19+
}
20+
21+
// ValidateQueryComplexity parses the query AST and rejects it if it exceeds
22+
// depth, alias, or total field count thresholds.
23+
func ValidateQueryComplexity(query string) error {
24+
doc, err := parser.Parse(parser.ParseParams{Source: query})
25+
if err != nil {
26+
return fmt.Errorf("query parse error: %w", err)
27+
}
28+
29+
c := &queryComplexity{}
30+
for _, def := range doc.Definitions {
31+
if op, ok := def.(*ast.OperationDefinition); ok {
32+
if err := c.walkSelectionSet(op.SelectionSet, 1); err != nil {
33+
return err
34+
}
35+
}
36+
}
37+
return nil
38+
}
39+
40+
func (c *queryComplexity) walkSelectionSet(ss *ast.SelectionSet, depth int) error {
41+
if ss == nil {
42+
return nil
43+
}
44+
if depth > MaxQueryDepth {
45+
return fmt.Errorf("query exceeds maximum depth of %d", MaxQueryDepth)
46+
}
47+
48+
for _, sel := range ss.Selections {
49+
switch s := sel.(type) {
50+
case *ast.Field:
51+
c.fields++
52+
if c.fields > MaxQueryFields {
53+
return fmt.Errorf("query exceeds maximum field count of %d", MaxQueryFields)
54+
}
55+
if s.Alias != nil && s.Alias.Value != "" {
56+
c.aliases++
57+
if c.aliases > MaxQueryAliases {
58+
return fmt.Errorf("query exceeds maximum alias count of %d", MaxQueryAliases)
59+
}
60+
}
61+
if err := c.walkSelectionSet(s.SelectionSet, depth+1); err != nil {
62+
return err
63+
}
64+
case *ast.InlineFragment:
65+
if err := c.walkSelectionSet(s.SelectionSet, depth+1); err != nil {
66+
return err
67+
}
68+
case *ast.FragmentSpread:
69+
c.fields++
70+
}
71+
}
72+
return nil
73+
}

internal/catalogd/server/handlers.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -223,15 +223,7 @@ func (h *CatalogHandlers) handleV1GraphQL(w http.ResponseWriter, r *http.Request
223223
return
224224
}
225225

226-
// Get catalog filesystem
227-
catalogFS, err := h.store.GetCatalogFS(catalog)
228-
if err != nil {
229-
httpError(w, err)
230-
return
231-
}
232-
233-
// Execute GraphQL query through the service
234-
result, err := h.graphqlSvc.ExecuteQuery(catalog, catalogFS, params.Query)
226+
result, err := h.graphqlSvc.ExecuteQuery(r.Context(), catalog, params.Query)
235227
if err != nil {
236228
httpError(w, err)
237229
return

internal/catalogd/server/handlers_test.go

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"net/http"
99
"net/http/httptest"
1010
"net/url"
11-
"os"
1211
"strings"
1312
"testing"
1413

@@ -146,12 +145,7 @@ func TestHandleV1GraphQL_Success(t *testing.T) {
146145
ctrl := gomock.NewController(t)
147146
rootURL, _ := url.Parse("http://localhost/")
148147

149-
// Create a temporary directory for the mock filesystem
150-
tmpDir := t.TempDir()
151-
catalogFS := os.DirFS(tmpDir)
152-
153148
store := mockcatalogdserver.NewMockCatalogStore(ctrl)
154-
store.EXPECT().GetCatalogFS("test-catalog").Return(catalogFS, nil)
155149

156150
expectedResult := &graphql.Result{
157151
Data: map[string]interface{}{
@@ -162,7 +156,7 @@ func TestHandleV1GraphQL_Success(t *testing.T) {
162156
}
163157

164158
graphqlSvc := mockcatalogdservice.NewMockGraphQLService(ctrl)
165-
graphqlSvc.EXPECT().ExecuteQuery("test-catalog", catalogFS, "{ summary { totalSchemas } }").Return(expectedResult, nil)
159+
graphqlSvc.EXPECT().ExecuteQuery(gomock.Any(), "test-catalog", "{ summary { totalSchemas } }").Return(expectedResult, nil)
166160

167161
handlers := server.NewCatalogHandlers(store, graphqlSvc, rootURL, server.MetasHandlerDisabled, server.GraphQLQueriesEnabled)
168162
handler := handlers.Handler()
@@ -201,14 +195,14 @@ func TestHandleV1GraphQL_Success(t *testing.T) {
201195
}
202196
}
203197

204-
func TestHandleV1GraphQL_GetCatalogFSError(t *testing.T) {
198+
func TestHandleV1GraphQL_CatalogNotFoundError(t *testing.T) {
205199
ctrl := gomock.NewController(t)
206200
rootURL, _ := url.Parse("http://localhost/")
207201

208202
store := mockcatalogdserver.NewMockCatalogStore(ctrl)
209-
store.EXPECT().GetCatalogFS("test-catalog").Return(nil, fs.ErrNotExist)
210203

211204
graphqlSvc := mockcatalogdservice.NewMockGraphQLService(ctrl)
205+
graphqlSvc.EXPECT().ExecuteQuery(gomock.Any(), "test-catalog", "{ summary { totalSchemas } }").Return(nil, fs.ErrNotExist)
212206

213207
handlers := server.NewCatalogHandlers(store, graphqlSvc, rootURL, server.MetasHandlerDisabled, server.GraphQLQueriesEnabled)
214208
handler := handlers.Handler()
@@ -228,14 +222,10 @@ func TestHandleV1GraphQL_ExecuteQueryError(t *testing.T) {
228222
ctrl := gomock.NewController(t)
229223
rootURL, _ := url.Parse("http://localhost/")
230224

231-
tmpDir := t.TempDir()
232-
catalogFS := os.DirFS(tmpDir)
233-
234225
store := mockcatalogdserver.NewMockCatalogStore(ctrl)
235-
store.EXPECT().GetCatalogFS("test-catalog").Return(catalogFS, nil)
236226

237227
graphqlSvc := mockcatalogdservice.NewMockGraphQLService(ctrl)
238-
graphqlSvc.EXPECT().ExecuteQuery("test-catalog", catalogFS, "{ summary { totalSchemas } }").Return(nil, context.DeadlineExceeded)
228+
graphqlSvc.EXPECT().ExecuteQuery(gomock.Any(), "test-catalog", "{ summary { totalSchemas } }").Return(nil, context.DeadlineExceeded)
239229

240230
handlers := server.NewCatalogHandlers(store, graphqlSvc, rootURL, server.MetasHandlerDisabled, server.GraphQLQueriesEnabled)
241231
handler := handlers.Handler()
@@ -255,10 +245,9 @@ func TestAllowedMethodsHandler_POSTOnlyForGraphQL(t *testing.T) {
255245
ctrl := gomock.NewController(t)
256246
rootURL, _ := url.Parse("http://localhost/")
257247
store := mockcatalogdserver.NewMockCatalogStore(ctrl)
258-
store.EXPECT().GetCatalogFS("test-catalog").Return(nil, nil)
259248

260249
graphqlSvc := mockcatalogdservice.NewMockGraphQLService(ctrl)
261-
graphqlSvc.EXPECT().ExecuteQuery("test-catalog", nil, "{ summary { totalSchemas } }").Return(nil, nil)
250+
graphqlSvc.EXPECT().ExecuteQuery(gomock.Any(), "test-catalog", "{ summary { totalSchemas } }").Return(nil, nil)
262251

263252
handlers := server.NewCatalogHandlers(store, graphqlSvc, rootURL, server.MetasHandlerDisabled, server.GraphQLQueriesEnabled)
264253
handler := handlers.Handler()

internal/catalogd/service/graphql_service.go

Lines changed: 40 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -3,70 +3,68 @@ package service
33
import (
44
"context"
55
"fmt"
6-
"io/fs"
76
"sync"
7+
"time"
88

99
"github.com/graphql-go/graphql"
10+
"github.com/graphql-go/graphql/gqlerrors"
1011
"golang.org/x/sync/singleflight"
1112

12-
"github.com/operator-framework/operator-registry/alpha/declcfg"
13-
1413
gql "github.com/operator-framework/operator-controller/internal/catalogd/graphql"
1514
)
1615

16+
// CatalogDataProvider provides access to catalog data for GraphQL schema building.
17+
// Implemented by the storage layer.
18+
type CatalogDataProvider interface {
19+
LoadCatalogSchema(catalog string) (*gql.CatalogSchema, error)
20+
NewObjectLoader(catalog string) (gql.ObjectLoader, error)
21+
}
22+
1723
// GraphQLService handles GraphQL schema generation and query execution for catalogs
1824
type GraphQLService interface {
19-
// GetSchema returns the GraphQL schema for a catalog, using cache if available
20-
GetSchema(catalog string, catalogFS fs.FS) (*gql.DynamicSchema, error)
21-
22-
// ExecuteQuery executes a GraphQL query against a catalog
23-
ExecuteQuery(catalog string, catalogFS fs.FS, query string) (*graphql.Result, error)
24-
25-
// InvalidateCache removes the cached schema for a catalog
25+
GetSchema(ctx context.Context, catalog string) (*gql.DynamicSchema, error)
26+
ExecuteQuery(ctx context.Context, catalog string, query string) (*graphql.Result, error)
2627
InvalidateCache(catalog string)
2728
}
2829

29-
// CachedGraphQLService implements GraphQLService with an in-memory schema cache
30+
// CachedGraphQLService implements GraphQLService with an in-memory schema cache.
31+
// The cached DynamicSchema contains only the GraphQL type system and schema metadata
32+
// (a few KB). Object data is loaded from disk at query time via the ObjectLoader.
3033
type CachedGraphQLService struct {
34+
provider CatalogDataProvider
3135
schemaMux sync.RWMutex
3236
schemaCache map[string]*gql.DynamicSchema
33-
buildGroup singleflight.Group // Prevents duplicate concurrent schema builds
37+
buildGroup singleflight.Group
3438
}
3539

36-
// NewCachedGraphQLService creates a new GraphQL service with caching
37-
func NewCachedGraphQLService() *CachedGraphQLService {
40+
func NewCachedGraphQLService(provider CatalogDataProvider) *CachedGraphQLService {
3841
return &CachedGraphQLService{
42+
provider: provider,
3943
schemaCache: make(map[string]*gql.DynamicSchema),
4044
}
4145
}
4246

43-
// GetSchema returns the GraphQL schema for a catalog, using cache if available
44-
func (s *CachedGraphQLService) GetSchema(catalog string, catalogFS fs.FS) (*gql.DynamicSchema, error) {
45-
// Check cache first (read lock)
47+
func (s *CachedGraphQLService) GetSchema(ctx context.Context, catalog string) (*gql.DynamicSchema, error) {
4648
s.schemaMux.RLock()
4749
if cachedSchema, ok := s.schemaCache[catalog]; ok {
4850
s.schemaMux.RUnlock()
4951
return cachedSchema, nil
5052
}
5153
s.schemaMux.RUnlock()
5254

53-
// Use singleflight to prevent duplicate concurrent builds for the same catalog
5455
result, err, _ := s.buildGroup.Do(catalog, func() (interface{}, error) {
55-
// Double-check cache after acquiring singleflight lock
5656
s.schemaMux.RLock()
5757
if cachedSchema, ok := s.schemaCache[catalog]; ok {
5858
s.schemaMux.RUnlock()
5959
return cachedSchema, nil
6060
}
6161
s.schemaMux.RUnlock()
6262

63-
// Schema not in cache, build it
64-
dynamicSchema, err := buildSchemaFromFS(catalogFS)
63+
dynamicSchema, err := s.buildSchema(catalog)
6564
if err != nil {
6665
return nil, err
6766
}
6867

69-
// Cache the result (write lock)
7068
s.schemaMux.Lock()
7169
s.schemaCache[catalog] = dynamicSchema
7270
s.schemaMux.Unlock()
@@ -81,80 +79,50 @@ func (s *CachedGraphQLService) GetSchema(catalog string, catalogFS fs.FS) (*gql.
8179
return result.(*gql.DynamicSchema), nil
8280
}
8381

84-
// ExecuteQuery executes a GraphQL query against a catalog
85-
func (s *CachedGraphQLService) ExecuteQuery(catalog string, catalogFS fs.FS, query string) (*graphql.Result, error) {
86-
// Get or build the schema (uses cache and singleflight)
87-
dynamicSchema, err := s.GetSchema(catalog, catalogFS)
82+
func (s *CachedGraphQLService) ExecuteQuery(ctx context.Context, catalog string, query string) (*graphql.Result, error) {
83+
if err := gql.ValidateQueryComplexity(query); err != nil {
84+
return &graphql.Result{
85+
Errors: []gqlerrors.FormattedError{
86+
gqlerrors.FormatError(err),
87+
},
88+
}, nil
89+
}
90+
91+
dynamicSchema, err := s.GetSchema(ctx, catalog)
8892
if err != nil {
8993
return nil, fmt.Errorf("failed to get GraphQL schema: %w", err)
9094
}
9195

92-
// Execute the query
96+
queryCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
97+
defer cancel()
98+
9399
result := graphql.Do(graphql.Params{
94100
Schema: dynamicSchema.Schema,
95101
RequestString: query,
102+
Context: queryCtx,
96103
})
97104

98105
return result, nil
99106
}
100107

101-
// InvalidateCache removes the cached schema for a catalog
102108
func (s *CachedGraphQLService) InvalidateCache(catalog string) {
103109
s.schemaMux.Lock()
104110
delete(s.schemaCache, catalog)
105111
s.schemaMux.Unlock()
106112
}
107113

108-
// buildSchemaFromFS builds a GraphQL schema from a catalog filesystem
109-
func buildSchemaFromFS(catalogFS fs.FS) (*gql.DynamicSchema, error) {
110-
var metas []*declcfg.Meta
111-
var metasMux sync.Mutex
112-
var walkErr error
113-
114-
// Collect all metas from the catalog filesystem
115-
// WalkMetasFS walks the filesystem concurrently, so we need to protect the metas slice and error
116-
err := declcfg.WalkMetasFS(context.Background(), catalogFS, func(path string, meta *declcfg.Meta, err error) error {
117-
metasMux.Lock()
118-
defer metasMux.Unlock()
119-
120-
if err != nil {
121-
// Set shared error so other goroutines can check
122-
if walkErr == nil {
123-
walkErr = err
124-
}
125-
return err
126-
}
127-
128-
// If an error has already occurred, skip further mutation
129-
if walkErr != nil {
130-
return walkErr
131-
}
132-
133-
if meta != nil {
134-
metas = append(metas, meta)
135-
}
136-
return nil
137-
})
114+
func (s *CachedGraphQLService) buildSchema(catalog string) (*gql.DynamicSchema, error) {
115+
catalogSchema, err := s.provider.LoadCatalogSchema(catalog)
138116
if err != nil {
139-
return nil, fmt.Errorf("error walking catalog metas: %w", err)
117+
return nil, fmt.Errorf("error loading catalog schema: %w", err)
140118
}
141119

142-
// Discover schema from collected metas
143-
catalogSchema, err := gql.DiscoverSchemaFromMetas(metas)
120+
loader, err := s.provider.NewObjectLoader(catalog)
144121
if err != nil {
145-
return nil, fmt.Errorf("error discovering schema: %w", err)
146-
}
147-
148-
// Organize metas by schema for resolvers
149-
metasBySchema := make(map[string][]*declcfg.Meta)
150-
for _, meta := range metas {
151-
if meta.Schema != "" {
152-
metasBySchema[meta.Schema] = append(metasBySchema[meta.Schema], meta)
153-
}
122+
return nil, fmt.Errorf("error creating object loader: %w", err)
154123
}
155124

156-
// Build dynamic GraphQL schema
157-
dynamicSchema, err := gql.BuildDynamicGraphQLSchema(catalogSchema, metasBySchema)
125+
dynamicSchema, err := gql.BuildDynamicGraphQLSchema(catalogSchema, loader)
158126
if err != nil {
159127
return nil, fmt.Errorf("error building GraphQL schema: %w", err)
160128
}

0 commit comments

Comments
 (0)