From 2600a2ff4982358b1e43322e25afa3f04e1c6abe Mon Sep 17 00:00:00 2001 From: quobix Date: Sun, 15 Feb 2026 12:50:05 -0500 Subject: [PATCH 1/4] use Clear() instead of Delete() --- datamodel/high/base/schema_proxy.go | 5 +---- datamodel/low/base/schema.go | 5 +---- datamodel/low/extraction_functions.go | 10 ++-------- index/utility_methods.go | 5 +---- utils/utils.go | 2 +- 5 files changed, 6 insertions(+), 21 deletions(-) diff --git a/datamodel/high/base/schema_proxy.go b/datamodel/high/base/schema_proxy.go index acd961d6..db8edf23 100644 --- a/datamodel/high/base/schema_proxy.go +++ b/datamodel/high/base/schema_proxy.go @@ -41,10 +41,7 @@ var inlineRenderingTracker sync.Map // ClearInlineRenderingTracker resets the inline rendering tracker. // Call this between document lifecycles in long-running processes to bound memory. func ClearInlineRenderingTracker() { - inlineRenderingTracker.Range(func(key, _ interface{}) bool { - inlineRenderingTracker.Delete(key) - return true - }) + inlineRenderingTracker.Clear() } // bundlingModeCount tracks the number of active bundling operations. diff --git a/datamodel/low/base/schema.go b/datamodel/low/base/schema.go index 2982ef8b..b5674324 100644 --- a/datamodel/low/base/schema.go +++ b/datamodel/low/base/schema.go @@ -199,10 +199,7 @@ var SchemaQuickHashMap sync.Map // ClearSchemaQuickHashMap resets the schema quick-hash cache. // Call this between document lifecycles in long-running processes to bound memory. func ClearSchemaQuickHashMap() { - SchemaQuickHashMap.Range(func(key, _ interface{}) bool { - SchemaQuickHashMap.Delete(key) - return true - }) + SchemaQuickHashMap.Clear() } func (s *Schema) hash(quick bool) uint64 { diff --git a/datamodel/low/extraction_functions.go b/datamodel/low/extraction_functions.go index b78896b0..0c61a354 100644 --- a/datamodel/low/extraction_functions.go +++ b/datamodel/low/extraction_functions.go @@ -46,14 +46,8 @@ var ErrExternalRefSkipped = errors.New("external reference resolution skipped") // ClearHashCache clears the global hash cache. This should be called before // starting a new document comparison to ensure clean state. func ClearHashCache() { - hashCache.Range(func(key, _ interface{}) bool { - hashCache.Delete(key) - return true - }) - indexCollectionCache.Range(func(key, _ interface{}) bool { - indexCollectionCache.Delete(key) - return true - }) + hashCache.Clear() + indexCollectionCache.Clear() } // GetStringBuilder retrieves a strings.Builder from the pool, resets it, and returns it. diff --git a/index/utility_methods.go b/index/utility_methods.go index 76a38edf..261b48bd 100644 --- a/index/utility_methods.go +++ b/index/utility_methods.go @@ -645,10 +645,7 @@ func syncMapToMap[K comparable, V any](sm *sync.Map) map[K]V { // ClearHashCache clears the hash cache - useful for testing and memory management func ClearHashCache() { - nodeHashCache.Range(func(key, value interface{}) bool { - nodeHashCache.Delete(key) - return true - }) + nodeHashCache.Clear() } // hasherPool pools maphash.Hash instances to avoid allocations. diff --git a/utils/utils.go b/utils/utils.go index 06c95acc..b9ab0cd0 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -76,7 +76,7 @@ func ClearJSONPathCache() { jsonPathCacheEager.Range(func(key, _ interface{}) bool { jsonPathCacheEager.Delete(key) return true - }) + jsonPathCache.Clear() } var jsonPathQuery = func(path *jsonpath.JSONPath, node *yaml.Node) []*yaml.Node { From 31354914c7dbea522f9ebbde3184d2b0f06a9882 Mon Sep 17 00:00:00 2001 From: quobix Date: Sun, 15 Feb 2026 20:18:07 -0500 Subject: [PATCH 2/4] Added `Release` as a new top level interface method. Cascades down through the stack to release pointers, maps, caches and more. Completely free up any remaining objects being held onto. --- cache.go | 5 ++ datamodel/low/hash.go | 9 +++ datamodel/spec_info.go | 11 ++++ datamodel/spec_info_test.go | 44 +++++++++++++ document.go | 25 ++++++++ document_test.go | 71 +++++++++++++++++++++ index/index_model.go | 117 ++++++++++++++++++++++++++++++++++ index/index_model_test.go | 122 ++++++++++++++++++++++++++++++++++++ index/resolver.go | 14 +++++ index/resolver_test.go | 30 +++++++++ index/rolodex.go | 26 ++++++++ index/rolodex_test.go | 61 ++++++++++++++++++ index/utility_methods.go | 18 ++++++ overlay_test.go | 1 + 14 files changed, 554 insertions(+) diff --git a/cache.go b/cache.go index 1db3e6a2..bf440c63 100644 --- a/cache.go +++ b/cache.go @@ -22,4 +22,9 @@ func ClearAllCaches() { index.ClearContentDetectionCache() highbase.ClearInlineRenderingTracker() utils.ClearJSONPathCache() + + // Drain sync.Pool instances that hold *yaml.Node pointers. + // Pooled slices/maps keep the entire YAML parse tree alive. + index.ClearNodePools() + low.ClearNodePools() } diff --git a/datamodel/low/hash.go b/datamodel/low/hash.go index 9feaa4b9..2d4ce549 100644 --- a/datamodel/low/hash.go +++ b/datamodel/low/hash.go @@ -47,6 +47,15 @@ func putVisitedMap(m map[*yaml.Node]bool) { visitedPool.Put(m) } +// ClearNodePools replaces the sync.Pool instances in this package that hold +// *yaml.Node pointers (visitedPool maps). After a document lifecycle ends, +// pooled maps still reference parsed YAML nodes, preventing GC collection. +func ClearNodePools() { + visitedPool = sync.Pool{ + New: func() any { return make(map[*yaml.Node]bool, 32) }, + } +} + // WithHasher provides a pooled hasher for the duration of fn. // The hasher is automatically returned to the pool after fn completes. // This pattern eliminates forgotten PutHasher() bugs. diff --git a/datamodel/spec_info.go b/datamodel/spec_info.go index 162b115e..f5200f04 100644 --- a/datamodel/spec_info.go +++ b/datamodel/spec_info.go @@ -40,6 +40,17 @@ type SpecInfo struct { Self string `json:"-"` // the $self field for OpenAPI 3.2+ documents (base URI) } +// Release nils fields that pin the YAML node tree and large byte arrays in memory. +func (s *SpecInfo) Release() { + if s == nil { + return + } + s.RootNode = nil + s.SpecBytes = nil + s.SpecJSONBytes = nil + s.SpecJSON = nil +} + func ExtractSpecInfoWithConfig(spec []byte, config *DocumentConfiguration) (*SpecInfo, error) { if config == nil { return extractSpecInfoInternal(spec, false, false) diff --git a/datamodel/spec_info_test.go b/datamodel/spec_info_test.go index c38a70d0..18816804 100644 --- a/datamodel/spec_info_test.go +++ b/datamodel/spec_info_test.go @@ -10,6 +10,7 @@ import ( "github.com/pb33f/libopenapi/utils" "github.com/stretchr/testify/assert" + "go.yaml.in/yaml/v4" ) const ( @@ -593,3 +594,46 @@ func TestExtractSpecInfo_ConfigSkipAsyncUnknown(t *testing.T) { assert.Error(t, e) assert.Nil(t, r) } + +func TestSpecInfo_Release(t *testing.T) { + specBytes := []byte("openapi: 3.1.0") + jsonBytes := []byte("{}") + jsonMap := map[string]interface{}{"openapi": "3.1.0"} + rootNode := &yaml.Node{Value: "root"} + + s := &SpecInfo{ + RootNode: rootNode, + SpecBytes: &specBytes, + SpecJSONBytes: &jsonBytes, + SpecJSON: &jsonMap, + Version: "3.1.0", + } + + s.Release() + + assert.Nil(t, s.RootNode) + assert.Nil(t, s.SpecBytes) + assert.Nil(t, s.SpecJSONBytes) + assert.Nil(t, s.SpecJSON) + // non-pointer fields are untouched + assert.Equal(t, "3.1.0", s.Version) +} + +func TestSpecInfo_Release_Nil(t *testing.T) { + var s *SpecInfo + s.Release() // must not panic +} + +func TestSpecInfo_Release_Idempotent(t *testing.T) { + s := &SpecInfo{RootNode: &yaml.Node{}} + s.Release() + s.Release() // second call must not panic + assert.Nil(t, s.RootNode) +} + +func TestSpecInfo_Release_EmptyFields(t *testing.T) { + s := &SpecInfo{} + s.Release() // all fields already nil/zero, must not panic + assert.Nil(t, s.RootNode) + assert.Nil(t, s.SpecBytes) +} diff --git a/document.go b/document.go index 5668edba..65b571a7 100644 --- a/document.go +++ b/document.go @@ -104,6 +104,11 @@ type Document interface { // Deprecated: This method is deprecated and will be removed in a future release. Use RenderAndReload() instead. // This method does not support mutations correctly. Serialize() ([]byte, error) + + // Release nils all internal state so that the YAML tree, SpecIndex, Rolodex, + // and model objects can be garbage-collected even if something still holds + // a reference to the Document interface value. + Release() } type document struct { @@ -171,6 +176,26 @@ func NewDocumentWithConfiguration(specByteArray []byte, configuration *datamodel return d, nil } +func (d *document) Release() { + if d == nil { + return + } + if d.info != nil { + d.info.Release() + d.info = nil + } + // This method intentionally does not call SpecIndex.Release(). Low-level + // model objects (Schema, PathItem, etc.) retain their own references to the + // SpecIndex and require its config and root node for hashing and comparison + // operations that may run after a Document is released. Callers that own the + // full lifecycle should call SpecIndex.Release() separately once all model + // consumers are finished. + d.rolodex = nil + d.config = nil + d.highOpenAPI3Model = nil + d.highSwaggerModel = nil +} + func (d *document) GetRolodex() *index.Rolodex { return d.rolodex } diff --git a/document_test.go b/document_test.go index 5c5cdebc..1978d971 100644 --- a/document_test.go +++ b/document_test.go @@ -2091,3 +2091,74 @@ components: refItem := schema.AllOf[1] assert.Equal(t, "#/components/schemas/Base", refItem.GetReference()) } + +func TestDocument_Release(t *testing.T) { + spec := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {}` + + doc, err := NewDocument([]byte(spec)) + require.NoError(t, err) + require.NotNil(t, doc) + + // build model so rolodex and high model are populated + _, _ = doc.BuildV3Model() + + // confirm fields are populated before release + assert.NotNil(t, doc.GetSpecInfo()) + assert.NotNil(t, doc.GetRolodex()) + + doc.Release() + + // after release, internal state is cleared + d := doc.(*document) + assert.Nil(t, d.info) + assert.Nil(t, d.rolodex) + assert.Nil(t, d.config) + assert.Nil(t, d.highOpenAPI3Model) +} + +func TestDocument_Release_Nil(t *testing.T) { + var d *document + d.Release() // must not panic +} + +func TestDocument_Release_Idempotent(t *testing.T) { + spec := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {}` + + doc, _ := NewDocument([]byte(spec)) + doc.Release() + doc.Release() // second call must not panic + + d := doc.(*document) + assert.Nil(t, d.info) +} + +func TestDocument_Release_PreservesSpecIndexForComparison(t *testing.T) { + spec := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {}` + + doc, _ := NewDocument([]byte(spec)) + _, _ = doc.BuildV3Model() + + rolodex := doc.GetRolodex() + require.NotNil(t, rolodex) + rootIdx := rolodex.GetRootIndex() + require.NotNil(t, rootIdx) + + // Release the document + doc.Release() + + // SpecIndex internals must NOT be released by Document.Release() + // (they're needed for hashing during what-changed comparisons) + assert.NotNil(t, rootIdx.GetConfig()) +} diff --git a/index/index_model.go b/index/index_model.go index 73ee111c..ca6e0137 100644 --- a/index/index_model.go +++ b/index/index_model.go @@ -442,6 +442,123 @@ func (index *SpecIndex) GetCache() *sync.Map { return index.cache } +// Release nils every field on SpecIndex that can pin YAML node trees, Reference +// maps, or large caches in memory. Call this once all consumers of the index are +// finished so the GC can reclaim the underlying data even if an interface value +// or escaped closure still holds a pointer to the SpecIndex struct itself. +func (index *SpecIndex) Release() { + if index == nil { + return + } + + // yaml.Node tree + index.root = nil + index.pathsNode = nil + index.tagsNode = nil + index.parametersNode = nil + index.schemasNode = nil + index.securitySchemesNode = nil + index.requestBodiesNode = nil + index.responsesNode = nil + index.headersNode = nil + index.examplesNode = nil + index.linksNode = nil + index.callbacksNode = nil + index.pathItemsNode = nil + index.rootServersNode = nil + index.rootSecurityNode = nil + + // reference maps (all hold *Reference with *yaml.Node pointers) + index.allRefs = nil + index.rawSequencedRefs = nil + index.linesWithRefs = nil + index.allMappedRefs = nil + index.allMappedRefsSequenced = nil + index.refsByLine = nil + index.pathRefs = nil + index.paramOpRefs = nil + index.paramCompRefs = nil + index.paramAllRefs = nil + index.paramInlineDuplicateNames = nil + index.globalTagRefs = nil + index.securitySchemeRefs = nil + index.requestBodiesRefs = nil + index.responsesRefs = nil + index.headersRefs = nil + index.examplesRefs = nil + index.securityRequirementRefs = nil + index.callbacksRefs = nil + index.linksRefs = nil + index.operationTagsRefs = nil + index.operationDescriptionRefs = nil + index.operationSummaryRefs = nil + index.callbackRefs = nil + index.serversRefs = nil + index.opServersRefs = nil + index.polymorphicRefs = nil + index.polymorphicAllOfRefs = nil + index.polymorphicOneOfRefs = nil + index.polymorphicAnyOfRefs = nil + index.externalDocumentsRef = nil + index.rootSecurity = nil + index.refsWithSiblings = nil + + // schema / component collections + index.allRefSchemaDefinitions = nil + index.allInlineSchemaDefinitions = nil + index.allInlineSchemaObjectDefinitions = nil + index.allComponentSchemaDefinitions = nil + index.allSecuritySchemes = nil + index.allComponentSchemas = nil + index.allParameters = nil + index.allRequestBodies = nil + index.allResponses = nil + index.allHeaders = nil + index.allExamples = nil + index.allLinks = nil + index.allCallbacks = nil + index.allComponentPathItems = nil + index.allExternalDocuments = nil + index.externalSpecIndex = nil + + // line/col -> *yaml.Node map + index.nodeMap = nil + index.allDescriptions = nil + index.allSummaries = nil + index.allEnums = nil + index.allObjectsWithProperties = nil + index.circularReferences = nil + index.polyCircularReferences = nil + index.arrayCircularReferences = nil + index.tagCircularReferences = nil + index.refErrors = nil + index.operationParamErrors = nil + index.cache = nil + index.highModelCache = nil + index.schemaIdRegistry = nil + index.pendingResolve = nil + index.uri = nil + index.logger = nil + + // Break circular SpecIndex <-> Resolver reference. + if index.resolver != nil { + index.resolver.Release() + index.resolver = nil + } + + // Rolodex holds rootNode and child indexes. + if index.rolodex != nil { + index.rolodex.Release() + index.rolodex = nil + } + + // Config holds SpecInfo which holds RootNode. + if index.config != nil { + index.config.SpecInfo.Release() + index.config = nil + } +} + // SetAbsolutePath sets the absolute path to the spec file for the index. Will be absolute, either as a http link or a file. func (index *SpecIndex) SetAbsolutePath(absolutePath string) { index.specAbsolutePath = absolutePath diff --git a/index/index_model_test.go b/index/index_model_test.go index 1203cf2e..f8c0a794 100644 --- a/index/index_model_test.go +++ b/index/index_model_test.go @@ -123,3 +123,125 @@ func TestSpecIndexConfig_ToDocumentConfiguration_SkipExternalRefResolution_False assert.NotNil(t, result) assert.False(t, result.SkipExternalRefResolution) } + +func TestSpecIndex_Release(t *testing.T) { + cfg := CreateOpenAPIIndexConfig() + rootNode := &yaml.Node{Value: "root"} + resolver := &Resolver{resolvedRoot: &yaml.Node{Value: "resolved"}} + + rolodex := NewRolodex(cfg) + rolodex.rootNode = &yaml.Node{Value: "rolodex-root"} + + idx := &SpecIndex{ + config: cfg, + root: rootNode, + pathsNode: &yaml.Node{}, + tagsNode: &yaml.Node{}, + schemasNode: &yaml.Node{}, + allRefs: map[string]*Reference{"ref": {}}, + rawSequencedRefs: []*Reference{{}}, + allMappedRefs: map[string]*Reference{"mapped": {}}, + allMappedRefsSequenced: []*ReferenceMapped{{}}, + nodeMap: map[int]map[int]*yaml.Node{1: {1: &yaml.Node{}}}, + allDescriptions: []*DescriptionReference{{}}, + allEnums: []*EnumReference{{}}, + circularReferences: []*CircularReferenceResult{{}}, + refErrors: []error{nil}, + resolver: resolver, + rolodex: rolodex, + allComponentSchemas: map[string]*Reference{"schema": {}}, + allExternalDocuments: map[string]*Reference{"ext": {}}, + externalSpecIndex: map[string]*SpecIndex{"ext": {}}, + schemaIdRegistry: map[string]*SchemaIdEntry{"id": {}}, + uri: []string{"test"}, + } + + idx.Release() + + // yaml.Node fields + assert.Nil(t, idx.root) + assert.Nil(t, idx.pathsNode) + assert.Nil(t, idx.tagsNode) + assert.Nil(t, idx.schemasNode) + + // reference maps + assert.Nil(t, idx.allRefs) + assert.Nil(t, idx.rawSequencedRefs) + assert.Nil(t, idx.allMappedRefs) + assert.Nil(t, idx.allMappedRefsSequenced) + + // node map + assert.Nil(t, idx.nodeMap) + + // descriptions, enums + assert.Nil(t, idx.allDescriptions) + assert.Nil(t, idx.allEnums) + + // circular refs, errors + assert.Nil(t, idx.circularReferences) + assert.Nil(t, idx.refErrors) + + // component schemas, external docs + assert.Nil(t, idx.allComponentSchemas) + assert.Nil(t, idx.allExternalDocuments) + assert.Nil(t, idx.externalSpecIndex) + + // schema ID registry, uri, logger + assert.Nil(t, idx.schemaIdRegistry) + assert.Nil(t, idx.uri) + assert.Nil(t, idx.logger) + + // resolver released and niled + assert.Nil(t, idx.resolver) + assert.Nil(t, resolver.specIndex) + assert.Nil(t, resolver.resolvedRoot) + + // rolodex released and niled + assert.Nil(t, idx.rolodex) + assert.Nil(t, rolodex.rootNode) + assert.Nil(t, rolodex.indexes) + + // config niled + assert.Nil(t, idx.config) +} + +func TestSpecIndex_Release_Nil(t *testing.T) { + var idx *SpecIndex + idx.Release() // must not panic +} + +func TestSpecIndex_Release_Idempotent(t *testing.T) { + idx := &SpecIndex{ + root: &yaml.Node{}, + config: CreateOpenAPIIndexConfig(), + resolver: &Resolver{}, + rolodex: NewRolodex(CreateOpenAPIIndexConfig()), + } + idx.Release() + idx.Release() // second call must not panic + assert.Nil(t, idx.root) + assert.Nil(t, idx.config) + assert.Nil(t, idx.resolver) + assert.Nil(t, idx.rolodex) +} + +func TestSpecIndex_Release_NilConfig(t *testing.T) { + idx := &SpecIndex{root: &yaml.Node{}} + idx.Release() // config is nil, must not panic + assert.Nil(t, idx.root) +} + +func TestSpecIndex_Release_ConfigWithNilSpecInfo(t *testing.T) { + idx := &SpecIndex{ + config: &SpecIndexConfig{}, // SpecInfo is nil + } + idx.Release() // SpecInfo.Release() called on nil, must not panic + assert.Nil(t, idx.config) +} + +func TestSpecIndex_Release_NilResolverAndRolodex(t *testing.T) { + idx := &SpecIndex{root: &yaml.Node{}} + // resolver and rolodex are nil + idx.Release() // must not panic + assert.Nil(t, idx.root) +} diff --git a/index/resolver.go b/index/resolver.go index 7a0fcba5..669f25fc 100644 --- a/index/resolver.go +++ b/index/resolver.go @@ -72,6 +72,20 @@ type Resolver struct { circChecked bool } +// Release nils all fields that can pin YAML node trees or SpecIndex references in +// memory. Call this once all consumers of the resolver are finished. +func (resolver *Resolver) Release() { + if resolver == nil { + return + } + resolver.specIndex = nil + resolver.resolvedRoot = nil + resolver.resolvingErrors = nil + resolver.circularReferences = nil + resolver.ignoredPolyReferences = nil + resolver.ignoredArrayReferences = nil +} + // NewResolver will create a new resolver from a *index.SpecIndex func NewResolver(index *SpecIndex) *Resolver { if index == nil { diff --git a/index/resolver_test.go b/index/resolver_test.go index 628d8973..8c56be6c 100644 --- a/index/resolver_test.go +++ b/index/resolver_test.go @@ -1820,4 +1820,34 @@ components: } } +func TestResolver_Release(t *testing.T) { + idx := &SpecIndex{config: CreateOpenAPIIndexConfig()} + resolver := NewResolver(idx) + resolver.resolvingErrors = []*ResolvingError{{}} + resolver.circularReferences = []*CircularReferenceResult{{}} + resolver.ignoredPolyReferences = []*CircularReferenceResult{{}} + resolver.ignoredArrayReferences = []*CircularReferenceResult{{}} + + resolver.Release() + + assert.Nil(t, resolver.specIndex) + assert.Nil(t, resolver.resolvedRoot) + assert.Nil(t, resolver.resolvingErrors) + assert.Nil(t, resolver.circularReferences) + assert.Nil(t, resolver.ignoredPolyReferences) + assert.Nil(t, resolver.ignoredArrayReferences) +} + +func TestResolver_Release_Nil(t *testing.T) { + var r *Resolver + r.Release() // must not panic +} + +func TestResolver_Release_Idempotent(t *testing.T) { + resolver := &Resolver{resolvedRoot: &yaml.Node{}} + resolver.Release() + resolver.Release() // second call must not panic + assert.Nil(t, resolver.resolvedRoot) +} + // func (resolver *Resolver) VisitReference(ref *Reference, seen map[string]bool, journey []*Reference, resolve bool) []*yaml.Node { diff --git a/index/rolodex.go b/index/rolodex.go index 1519f9f0..4effb714 100644 --- a/index/rolodex.go +++ b/index/rolodex.go @@ -91,6 +91,32 @@ type Rolodex struct { schemaIdRegistryLock sync.RWMutex } +// Release nils all fields that can pin YAML node trees, SpecIndex objects, or +// circular reference results in memory. Acquires locks for fields that are +// protected elsewhere. Call this once all consumers of the rolodex are finished. +func (r *Rolodex) Release() { + if r == nil { + return + } + r.indexLock.Lock() + r.indexes = nil + r.indexMap = nil + r.rootIndex = nil + r.indexLock.Unlock() + + r.circRefCacheLock.Lock() + r.debouncedSafeCircRefs = nil + r.debouncedIgnoredCircRefs = nil + r.circRefCacheLock.Unlock() + + r.rootNode = nil + r.caughtErrors = nil + r.safeCircularReferences = nil + r.infiniteCircularReferences = nil + r.ignoredCircularReferences = nil + r.globalSchemaIdRegistry = nil +} + // NewRolodex creates a new rolodex with the provided index configuration. func NewRolodex(indexConfig *SpecIndexConfig) *Rolodex { logger := indexConfig.Logger diff --git a/index/rolodex_test.go b/index/rolodex_test.go index a0b923eb..1bff32f9 100644 --- a/index/rolodex_test.go +++ b/index/rolodex_test.go @@ -2625,3 +2625,64 @@ paths: {} assert.True(t, strings.HasSuffix(rolodex.indexConfig.SpecAbsolutePath, filepath.Join("dir2", "doc.yaml"))) }) } + +func TestRolodex_Release(t *testing.T) { + cfg := CreateOpenAPIIndexConfig() + rolodex := NewRolodex(cfg) + idx := &SpecIndex{config: cfg} + rolodex.AddIndex(idx) + rolodex.rootNode = &yaml.Node{Value: "root"} + rolodex.rootIndex = idx + rolodex.caughtErrors = []error{fmt.Errorf("test")} + rolodex.safeCircularReferences = []*CircularReferenceResult{{}} + rolodex.infiniteCircularReferences = []*CircularReferenceResult{{}} + rolodex.ignoredCircularReferences = []*CircularReferenceResult{{}} + rolodex.debouncedSafeCircRefs = []*CircularReferenceResult{{}} + rolodex.debouncedIgnoredCircRefs = []*CircularReferenceResult{{}} + rolodex.globalSchemaIdRegistry = map[string]*SchemaIdEntry{"test": {}} + + rolodex.Release() + + assert.Nil(t, rolodex.indexes) + assert.Nil(t, rolodex.indexMap) + assert.Nil(t, rolodex.rootIndex) + assert.Nil(t, rolodex.rootNode) + assert.Nil(t, rolodex.caughtErrors) + assert.Nil(t, rolodex.safeCircularReferences) + assert.Nil(t, rolodex.infiniteCircularReferences) + assert.Nil(t, rolodex.ignoredCircularReferences) + assert.Nil(t, rolodex.debouncedSafeCircRefs) + assert.Nil(t, rolodex.debouncedIgnoredCircRefs) + assert.Nil(t, rolodex.globalSchemaIdRegistry) +} + +func TestRolodex_Release_Nil(t *testing.T) { + var r *Rolodex + r.Release() // must not panic +} + +func TestRolodex_Release_Idempotent(t *testing.T) { + cfg := CreateOpenAPIIndexConfig() + rolodex := NewRolodex(cfg) + rolodex.rootNode = &yaml.Node{} + rolodex.Release() + rolodex.Release() // second call must not panic + assert.Nil(t, rolodex.rootNode) +} + +func TestRolodex_Release_ConcurrentSafe(t *testing.T) { + cfg := CreateOpenAPIIndexConfig() + rolodex := NewRolodex(cfg) + idx := &SpecIndex{config: cfg} + rolodex.AddIndex(idx) + + // Release acquires locks, so calling it concurrently with GetIndexes must not race. + done := make(chan struct{}) + go func() { + rolodex.Release() + close(done) + }() + // GetIndexes also acquires indexLock, so this tests lock correctness. + _ = rolodex.GetIndexes() + <-done +} diff --git a/index/utility_methods.go b/index/utility_methods.go index 261b48bd..ebb84323 100644 --- a/index/utility_methods.go +++ b/index/utility_methods.go @@ -648,6 +648,24 @@ func ClearHashCache() { nodeHashCache.Clear() } +// ClearNodePools replaces the sync.Pool instances that hold *yaml.Node pointers +// with fresh pools. After a document lifecycle ends, pooled slices and maps +// still reference the parsed YAML tree, preventing GC from collecting it. +// Call this (via libopenapi.ClearAllCaches) to release those references. +func ClearNodePools() { + stackPool = sync.Pool{ + New: func() interface{} { + s := make([]*yaml.Node, 0, 128) + return &s + }, + } + visitedPool = sync.Pool{ + New: func() interface{} { + return make(map[*yaml.Node]struct{}, 64) + }, + } +} + // hasherPool pools maphash.Hash instances to avoid allocations. // maphash is ~15x faster than SHA256 and has native WriteString support. var hasherPool = sync.Pool{ diff --git a/overlay_test.go b/overlay_test.go index b5e0a60a..06667f0a 100644 --- a/overlay_test.go +++ b/overlay_test.go @@ -455,6 +455,7 @@ func (m *mockDocument) Serialize() ([]byte, error) { return nil, nil } func (m *mockDocument) RenderAndReload() ([]byte, Document, *DocumentModel[v3.Document], error) { return nil, nil, nil, nil } +func (m *mockDocument) Release() {} func TestApplyOverlay_NilSpecBytes(t *testing.T) { // Test line 63: specBytes == nil From b119acc0b7cfa470a5dece3482220d945674978d Mon Sep 17 00:00:00 2001 From: quobix Date: Wed, 25 Feb 2026 09:26:38 -0500 Subject: [PATCH 3/4] bump coverage --- datamodel/low/hash_test.go | 14 ++++++++++++++ index/utility_methods_buffer_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/datamodel/low/hash_test.go b/datamodel/low/hash_test.go index d714283a..8b389e8b 100644 --- a/datamodel/low/hash_test.go +++ b/datamodel/low/hash_test.go @@ -115,3 +115,17 @@ func TestGetPutVisitedMap_Reuse(t *testing.T) { assert.Empty(t, m2) putVisitedMap(m2) } + +func TestClearNodePools(t *testing.T) { + // Ensure existing pool values are in use before replacing the pool. + initial := getVisitedMap() + initial[&yaml.Node{Value: "old"}] = true + putVisitedMap(initial) + + ClearNodePools() + + fresh := getVisitedMap() + assert.NotNil(t, fresh) + assert.Empty(t, fresh) + putVisitedMap(fresh) +} diff --git a/index/utility_methods_buffer_test.go b/index/utility_methods_buffer_test.go index ead3be1e..8d911c39 100644 --- a/index/utility_methods_buffer_test.go +++ b/index/utility_methods_buffer_test.go @@ -242,6 +242,31 @@ func TestClearHashCache_ComprehensiveTest(t *testing.T) { } } +func TestClearNodePools(t *testing.T) { + // Seed current pools with data so a reset must provide fresh containers. + oldStack := stackPool.Get().(*[]*yaml.Node) + *oldStack = append(*oldStack, &yaml.Node{Value: "stale"}) + stackPool.Put(oldStack) + + oldVisited := visitedPool.Get().(map[*yaml.Node]struct{}) + oldVisited[&yaml.Node{Value: "stale"}] = struct{}{} + visitedPool.Put(oldVisited) + + ClearNodePools() + + newStack := stackPool.Get().(*[]*yaml.Node) + assert.NotNil(t, newStack) + assert.Empty(t, *newStack) + assert.GreaterOrEqual(t, cap(*newStack), 128) + + newVisited := visitedPool.Get().(map[*yaml.Node]struct{}) + assert.NotNil(t, newVisited) + assert.Empty(t, newVisited) + + stackPool.Put(newStack) + visitedPool.Put(newVisited) +} + // Test HashNode with large node that triggers caching func TestHashNode_LargeNodeCaching(t *testing.T) { // Create a node with >= 200 content items to trigger caching From c4e35e544b82f4a81dc99a65ac4ccb6a9f5630bb Mon Sep 17 00:00:00 2001 From: quobix Date: Thu, 26 Feb 2026 16:10:12 -0500 Subject: [PATCH 4/4] fixed build --- utils/utils.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/utils/utils.go b/utils/utils.go index b9ab0cd0..09d5eb43 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -69,14 +69,8 @@ var jsonPathCacheEager sync.Map // ClearJSONPathCache resets the compiled JSONPath cache. // Call this between document lifecycles in long-running processes to bound memory. func ClearJSONPathCache() { - jsonPathCacheLazy.Range(func(key, _ interface{}) bool { - jsonPathCacheLazy.Delete(key) - return true - }) - jsonPathCacheEager.Range(func(key, _ interface{}) bool { - jsonPathCacheEager.Delete(key) - return true - jsonPathCache.Clear() + jsonPathCacheLazy.Clear() + jsonPathCacheEager.Clear() } var jsonPathQuery = func(path *jsonpath.JSONPath, node *yaml.Node) []*yaml.Node {