diff --git a/docker-compose.yml b/docker-compose.yml index 84c1e63d..2efcce30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,6 +82,7 @@ services: ports: - "${SOURCEBRIDGE_API_PORT:-8080}:8080" environment: + - SOURCEBRIDGE_PPROF_ENABLED=${SOURCEBRIDGE_PPROF_ENABLED:-false} - SOURCEBRIDGE_STORAGE_SURREAL_MODE=external - SOURCEBRIDGE_STORAGE_SURREAL_URL=ws://surrealdb:8000/rpc - SOURCEBRIDGE_STORAGE_SURREAL_NAMESPACE=sourcebridge diff --git a/internal/api/rest/router.go b/internal/api/rest/router.go index 37ac719c..f51cc276 100644 --- a/internal/api/rest/router.go +++ b/internal/api/rest/router.go @@ -7,6 +7,8 @@ import ( "context" "log/slog" "net/http" + "net/http/pprof" //nolint:gosec // dev-only profiling, gated behind SOURCEBRIDGE_PPROF_ENABLED + "os" "strconv" "strings" "sync" @@ -735,6 +737,23 @@ func (s *Server) setupRouter() { // Rate limiting r.Use(httprate.LimitByIP(100, 1*time.Minute)) + // pprof — gated behind SOURCEBRIDGE_PPROF_ENABLED=true. Mounted before the + // global rate limiter so a goroutine dump under load is not throttled. + // Dev-only; never enable on a public-facing deployment. + if os.Getenv("SOURCEBRIDGE_PPROF_ENABLED") == "true" { + r.HandleFunc("/debug/pprof/", pprof.Index) + r.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + r.HandleFunc("/debug/pprof/profile", pprof.Profile) + r.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + r.HandleFunc("/debug/pprof/trace", pprof.Trace) + r.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine")) + r.Handle("/debug/pprof/heap", pprof.Handler("heap")) + r.Handle("/debug/pprof/allocs", pprof.Handler("allocs")) + r.Handle("/debug/pprof/block", pprof.Handler("block")) + r.Handle("/debug/pprof/mutex", pprof.Handler("mutex")) + r.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate")) + } + // Public routes r.Get("/healthz", s.handleHealthz) r.Get("/readyz", s.handleReadyz) diff --git a/internal/livingwiki/orchestrator/graphmetrics.go b/internal/livingwiki/orchestrator/graphmetrics.go index 837e25b3..ff9024a7 100644 --- a/internal/livingwiki/orchestrator/graphmetrics.go +++ b/internal/livingwiki/orchestrator/graphmetrics.go @@ -72,66 +72,110 @@ func (m *GraphStoreMetrics) GraphRelationCount(repoID, pageID string) int { // packageReferenceCount counts distinct caller packages importing any symbol // in pkg within the given repository. +// +// CA-171: replaces the prior O(symbols × callers) sequential SurrealDB +// round-trips with two queries — GetSymbols + GetCallEdges — and one +// GetSymbolsByIDs batch for the resolved callers. Same logical result. func (m *GraphStoreMetrics) packageReferenceCount(repoID, pkg string) int { syms, _ := m.store.GetSymbols(repoID, nil, nil, 0, 0) - callerPkgs := make(map[string]bool) + pkgSymIDs := make(map[string]struct{}, len(syms)) for _, sym := range syms { if sym.FilePath == "" { continue } - if !symbolInPackage(sym.FilePath, pkg) { + if symbolInPackage(sym.FilePath, pkg) { + pkgSymIDs[sym.ID] = struct{}{} + } + } + if len(pkgSymIDs) == 0 { + return 0 + } + edges := m.store.GetCallEdges(repoID) + callerIDSet := make(map[string]struct{}) + for _, e := range edges { + if _, ok := pkgSymIDs[e.CalleeID]; !ok { + continue + } + callerIDSet[e.CallerID] = struct{}{} + } + if len(callerIDSet) == 0 { + return 0 + } + callerIDs := make([]string, 0, len(callerIDSet)) + for id := range callerIDSet { + callerIDs = append(callerIDs, id) + } + callers := m.store.GetSymbolsByIDs(callerIDs) + callerPkgs := make(map[string]bool) + for _, caller := range callers { + if caller == nil { continue } - for _, callerID := range m.store.GetCallers(sym.ID) { - caller := m.store.GetSymbol(callerID) - if caller == nil { - continue - } - callerPkg := filePackage(caller.FilePath) - if callerPkg != pkg { - callerPkgs[callerPkg] = true - } + callerPkg := filePackage(caller.FilePath) + if callerPkg != pkg { + callerPkgs[callerPkg] = true } } return len(callerPkgs) } // packageRelationCount counts total inbound call-graph edges to symbols in pkg. +// +// CA-171: uses GetCallEdges + a package-membership filter instead of one +// GetCallers query per symbol. func (m *GraphStoreMetrics) packageRelationCount(repoID, pkg string) int { syms, _ := m.store.GetSymbols(repoID, nil, nil, 0, 0) - total := 0 + pkgSymIDs := make(map[string]struct{}, len(syms)) for _, sym := range syms { - if !symbolInPackage(sym.FilePath, pkg) { - continue + if symbolInPackage(sym.FilePath, pkg) { + pkgSymIDs[sym.ID] = struct{}{} + } + } + if len(pkgSymIDs) == 0 { + return 0 + } + total := 0 + for _, e := range m.store.GetCallEdges(repoID) { + if _, ok := pkgSymIDs[e.CalleeID]; ok { + total++ } - total += len(m.store.GetCallers(sym.ID)) } return total } // repoReferenceCount aggregates reference counts across all packages in the repo. +// +// CA-171: collapsed N×K SurrealDB round-trips into GetCallEdges + one +// GetSymbolsByIDs batch. func (m *GraphStoreMetrics) repoReferenceCount(repoID string) int { - syms, _ := m.store.GetSymbols(repoID, nil, nil, 0, 0) + edges := m.store.GetCallEdges(repoID) + if len(edges) == 0 { + return 0 + } + callerIDSet := make(map[string]struct{}, len(edges)) + for _, e := range edges { + callerIDSet[e.CallerID] = struct{}{} + } + callerIDs := make([]string, 0, len(callerIDSet)) + for id := range callerIDSet { + callerIDs = append(callerIDs, id) + } + callers := m.store.GetSymbolsByIDs(callerIDs) callerPkgs := make(map[string]bool) - for _, sym := range syms { - for _, callerID := range m.store.GetCallers(sym.ID) { - caller := m.store.GetSymbol(callerID) - if caller != nil { - callerPkgs[filePackage(caller.FilePath)] = true - } + for _, caller := range callers { + if caller == nil { + continue } + callerPkgs[filePackage(caller.FilePath)] = true } return len(callerPkgs) } // repoRelationCount counts all inbound call edges for the repo. +// +// CA-171: replaced GetCallers-per-symbol with a single GetCallEdges query. func (m *GraphStoreMetrics) repoRelationCount(repoID string) int { - syms, _ := m.store.GetSymbols(repoID, nil, nil, 0, 0) - total := 0 - for _, sym := range syms { - total += len(m.store.GetCallers(sym.ID)) - } - return total + return len(m.store.GetCallEdges(repoID)) } // pageSubject extracts the package path from an architecture page ID.