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
5 changes: 5 additions & 0 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
5 changes: 1 addition & 4 deletions datamodel/high/base/schema_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 1 addition & 4 deletions datamodel/low/base/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 2 additions & 8 deletions datamodel/low/extraction_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions datamodel/low/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions datamodel/low/hash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
11 changes: 11 additions & 0 deletions datamodel/spec_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions datamodel/spec_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/pb33f/libopenapi/utils"
"github.com/stretchr/testify/assert"
"go.yaml.in/yaml/v4"
)

const (
Expand Down Expand Up @@ -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)
}
25 changes: 25 additions & 0 deletions document.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
71 changes: 71 additions & 0 deletions document_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
117 changes: 117 additions & 0 deletions index/index_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading