-
Notifications
You must be signed in to change notification settings - Fork 38
Schema Caching For Request and Response Bodies #187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
daveshanley
merged 16 commits into
pb33f:main
from
its-hammer-time:reduce-memory-pressure-from-request-time-schema-compilation
Oct 22, 2025
Merged
Changes from 11 commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
bd95c0a
Schema Caching For Request and Response Bodies
its-hammer-time b5cc77a
Cleanup And Add Missing Test Coverage
its-hammer-time 098ce58
More Cleanup And Tests
its-hammer-time 58c29a3
More Tests
its-hammer-time 4800417
Flip Logic To Increase Test Coverage
its-hammer-time 30bfc52
Reduce Number Of YAML Unmarshals For Error Case
its-hammer-time 49a99a3
Add Query And AdditionalOperations To Tests
its-hammer-time 8076910
Refactor Validate Functions To Struct Inputs
its-hammer-time a0a1a0b
Linting
its-hammer-time 113c9f5
Empty Change To Start CI
its-hammer-time e542a8d
Code Coverage
its-hammer-time a6e476b
Fix Unit Tests
its-hammer-time 9cd6660
Slight Formatting Changes
its-hammer-time db7e179
Ignore Rendering Failures To Keep Consistent Behavior
its-hammer-time bbe9c04
It's 2025
its-hammer-time ba05c7a
It's 2025
its-hammer-time File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| // Copyright 2023 Princess B33f Heavy Industries / Dave Shanley | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package cache | ||
|
|
||
| import ( | ||
| "github.com/pb33f/libopenapi/datamodel/high/base" | ||
| "github.com/santhosh-tekuri/jsonschema/v6" | ||
| ) | ||
|
|
||
| // SchemaCacheEntry holds a compiled schema and its intermediate representations. | ||
| // This is stored in the cache to avoid re-rendering and re-compiling schemas on each request. | ||
| type SchemaCacheEntry struct { | ||
| Schema *base.Schema | ||
| RenderedInline []byte | ||
| RenderedJSON []byte | ||
| CompiledSchema *jsonschema.Schema | ||
| } | ||
|
|
||
| // SchemaCache defines the interface for schema caching implementations. | ||
| // The key is a [32]byte hash of the schema (from schema.GoLow().Hash()). | ||
| type SchemaCache interface { | ||
| Load(key [32]byte) (*SchemaCacheEntry, bool) | ||
| Store(key [32]byte, value *SchemaCacheEntry) | ||
| Range(f func(key [32]byte, value *SchemaCacheEntry) bool) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,308 @@ | ||
| // Copyright 2023 Princess B33f Heavy Industries / Dave Shanley | ||
|
||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package cache | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/pb33f/libopenapi/datamodel/high/base" | ||
| "github.com/santhosh-tekuri/jsonschema/v6" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestNewDefaultCache(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
| assert.NotNil(t, cache) | ||
| assert.NotNil(t, cache.m) | ||
| } | ||
|
|
||
| func TestDefaultCache_StoreAndLoad(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
|
|
||
| // Create a test schema cache entry | ||
| testSchema := &SchemaCacheEntry{ | ||
| Schema: &base.Schema{}, | ||
| RenderedInline: []byte("rendered"), | ||
| RenderedJSON: []byte(`{"type":"object"}`), | ||
| CompiledSchema: &jsonschema.Schema{}, | ||
| } | ||
|
|
||
| // Create a test key (32-byte hash) | ||
| var key [32]byte | ||
| copy(key[:], []byte("test-schema-hash-12345678901234")) | ||
|
|
||
| // Store the schema | ||
| cache.Store(key, testSchema) | ||
|
|
||
| // Load the schema back | ||
| loaded, ok := cache.Load(key) | ||
| assert.True(t, ok, "Should find the cached schema") | ||
| require.NotNil(t, loaded) | ||
| assert.Equal(t, testSchema.RenderedInline, loaded.RenderedInline) | ||
| assert.Equal(t, testSchema.RenderedJSON, loaded.RenderedJSON) | ||
| assert.NotNil(t, loaded.CompiledSchema) | ||
| } | ||
|
|
||
| func TestDefaultCache_LoadMissing(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
|
|
||
| // Try to load a key that doesn't exist | ||
| var key [32]byte | ||
| copy(key[:], []byte("nonexistent-key-12345678901234")) | ||
|
|
||
| loaded, ok := cache.Load(key) | ||
| assert.False(t, ok, "Should not find non-existent key") | ||
| assert.Nil(t, loaded) | ||
| } | ||
|
|
||
| func TestDefaultCache_LoadNilCache(t *testing.T) { | ||
| var cache *DefaultCache | ||
|
|
||
| var key [32]byte | ||
| loaded, ok := cache.Load(key) | ||
|
|
||
| assert.False(t, ok) | ||
| assert.Nil(t, loaded) | ||
| } | ||
|
|
||
| func TestDefaultCache_StoreNilCache(t *testing.T) { | ||
| var cache *DefaultCache | ||
|
|
||
| // Should not panic | ||
| var key [32]byte | ||
| cache.Store(key, &SchemaCacheEntry{}) | ||
|
|
||
| // Verify nothing was stored (cache is nil) | ||
| assert.Nil(t, cache) | ||
| } | ||
|
|
||
| func TestDefaultCache_Range(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
|
|
||
| // Store multiple entries | ||
| entries := make(map[[32]byte]*SchemaCacheEntry) | ||
| for i := 0; i < 5; i++ { | ||
| var key [32]byte | ||
| copy(key[:], []byte{byte(i)}) | ||
|
|
||
| entry := &SchemaCacheEntry{ | ||
| RenderedInline: []byte{byte(i)}, | ||
| RenderedJSON: []byte{byte(i)}, | ||
| } | ||
| entries[key] = entry | ||
| cache.Store(key, entry) | ||
| } | ||
|
|
||
| // Range over all entries | ||
| count := 0 | ||
| foundKeys := make(map[[32]byte]bool) | ||
| cache.Range(func(key [32]byte, value *SchemaCacheEntry) bool { | ||
| count++ | ||
| foundKeys[key] = true | ||
|
|
||
| // Verify the value matches what we stored | ||
| expected, exists := entries[key] | ||
| assert.True(t, exists, "Key should exist in original entries") | ||
| assert.Equal(t, expected.RenderedInline, value.RenderedInline) | ||
| return true | ||
| }) | ||
|
|
||
| assert.Equal(t, 5, count, "Should iterate over all 5 entries") | ||
| assert.Equal(t, 5, len(foundKeys), "Should find all 5 unique keys") | ||
| } | ||
|
|
||
| func TestDefaultCache_RangeEarlyTermination(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
|
|
||
| // Store multiple entries | ||
| for i := 0; i < 10; i++ { | ||
| var key [32]byte | ||
| copy(key[:], []byte{byte(i)}) | ||
| cache.Store(key, &SchemaCacheEntry{}) | ||
| } | ||
|
|
||
| // Range but stop after 3 iterations | ||
| count := 0 | ||
| cache.Range(func(key [32]byte, value *SchemaCacheEntry) bool { | ||
| count++ | ||
| return count < 3 // Stop after 3 | ||
| }) | ||
|
|
||
| assert.Equal(t, 3, count, "Should stop after 3 iterations") | ||
| } | ||
|
|
||
| func TestDefaultCache_RangeNilCache(t *testing.T) { | ||
| var cache *DefaultCache | ||
|
|
||
| // Should not panic | ||
| called := false | ||
| cache.Range(func(key [32]byte, value *SchemaCacheEntry) bool { | ||
| called = true | ||
| return true | ||
| }) | ||
|
|
||
| assert.False(t, called, "Callback should not be called on nil cache") | ||
| } | ||
|
|
||
| func TestDefaultCache_RangeEmpty(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
|
|
||
| // Range over empty cache | ||
| count := 0 | ||
| cache.Range(func(key [32]byte, value *SchemaCacheEntry) bool { | ||
| count++ | ||
| return true | ||
| }) | ||
|
|
||
| assert.Equal(t, 0, count, "Should not iterate over empty cache") | ||
| } | ||
|
|
||
| func TestDefaultCache_Overwrite(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
|
|
||
| var key [32]byte | ||
| copy(key[:], []byte("test-key")) | ||
|
|
||
| // Store first value | ||
| first := &SchemaCacheEntry{ | ||
| RenderedInline: []byte("first"), | ||
| } | ||
| cache.Store(key, first) | ||
|
|
||
| // Store second value with same key | ||
| second := &SchemaCacheEntry{ | ||
| RenderedInline: []byte("second"), | ||
| } | ||
| cache.Store(key, second) | ||
|
|
||
| // Load should return the second value | ||
| loaded, ok := cache.Load(key) | ||
| assert.True(t, ok) | ||
| require.NotNil(t, loaded) | ||
| assert.Equal(t, []byte("second"), loaded.RenderedInline) | ||
| } | ||
|
|
||
| func TestDefaultCache_MultipleKeys(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
|
|
||
| // Store with different keys | ||
| var key1, key2, key3 [32]byte | ||
| copy(key1[:], []byte("key1")) | ||
| copy(key2[:], []byte("key2")) | ||
| copy(key3[:], []byte("key3")) | ||
|
|
||
| cache.Store(key1, &SchemaCacheEntry{RenderedInline: []byte("value1")}) | ||
| cache.Store(key2, &SchemaCacheEntry{RenderedInline: []byte("value2")}) | ||
| cache.Store(key3, &SchemaCacheEntry{RenderedInline: []byte("value3")}) | ||
|
|
||
| // Load each one | ||
| val1, ok1 := cache.Load(key1) | ||
| val2, ok2 := cache.Load(key2) | ||
| val3, ok3 := cache.Load(key3) | ||
|
|
||
| assert.True(t, ok1) | ||
| assert.True(t, ok2) | ||
| assert.True(t, ok3) | ||
|
|
||
| assert.Equal(t, []byte("value1"), val1.RenderedInline) | ||
| assert.Equal(t, []byte("value2"), val2.RenderedInline) | ||
| assert.Equal(t, []byte("value3"), val3.RenderedInline) | ||
| } | ||
|
|
||
| func TestDefaultCache_ThreadSafety(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
|
|
||
| // Concurrent writes | ||
| done := make(chan bool, 10) | ||
| for i := 0; i < 10; i++ { | ||
| go func(val int) { | ||
| var key [32]byte | ||
| copy(key[:], []byte{byte(val)}) | ||
| cache.Store(key, &SchemaCacheEntry{ | ||
| RenderedInline: []byte{byte(val)}, | ||
| }) | ||
| done <- true | ||
| }(i) | ||
| } | ||
|
|
||
| // Wait for all writes | ||
| for i := 0; i < 10; i++ { | ||
| <-done | ||
| } | ||
|
|
||
| // Concurrent reads | ||
| for i := 0; i < 10; i++ { | ||
| go func(val int) { | ||
| var key [32]byte | ||
| copy(key[:], []byte{byte(val)}) | ||
| loaded, ok := cache.Load(key) | ||
| assert.True(t, ok) | ||
| assert.NotNil(t, loaded) | ||
| done <- true | ||
| }(i) | ||
| } | ||
|
|
||
| // Wait for all reads | ||
| for i := 0; i < 10; i++ { | ||
| <-done | ||
| } | ||
|
|
||
| // Verify all entries exist | ||
| count := 0 | ||
| cache.Range(func(key [32]byte, value *SchemaCacheEntry) bool { | ||
| count++ | ||
| return true | ||
| }) | ||
| assert.Equal(t, 10, count, "All entries should be present") | ||
| } | ||
|
|
||
| func TestSchemaCache_Fields(t *testing.T) { | ||
| // Test that SchemaCache properly holds all fields | ||
| schema := &base.Schema{} | ||
| compiled := &jsonschema.Schema{} | ||
|
|
||
| sc := &SchemaCacheEntry{ | ||
| Schema: schema, | ||
| RenderedInline: []byte("rendered"), | ||
| RenderedJSON: []byte(`{"type":"object"}`), | ||
| CompiledSchema: compiled, | ||
| } | ||
|
|
||
| assert.Equal(t, schema, sc.Schema) | ||
| assert.Equal(t, []byte("rendered"), sc.RenderedInline) | ||
| assert.Equal(t, []byte(`{"type":"object"}`), sc.RenderedJSON) | ||
| assert.Equal(t, compiled, sc.CompiledSchema) | ||
| } | ||
|
|
||
| func TestDefaultCache_RangeWithInvalidTypes(t *testing.T) { | ||
| cache := NewDefaultCache() | ||
|
|
||
| // Manually insert invalid types into the underlying sync.Map to test defensive programming | ||
| // Store an entry with wrong key type | ||
| cache.m.Store("invalid-key-type", &SchemaCacheEntry{}) | ||
|
|
||
| // Store an entry with wrong value type | ||
| var validKey [32]byte | ||
| copy(validKey[:], []byte{1}) | ||
| cache.m.Store(validKey, "invalid-value-type") | ||
|
|
||
| // Store a valid entry | ||
| var validKey2 [32]byte | ||
| copy(validKey2[:], []byte{2}) | ||
| validEntry := &SchemaCacheEntry{RenderedInline: []byte("valid")} | ||
| cache.Store(validKey2, validEntry) | ||
|
|
||
| // Range should skip invalid entries and only process valid ones | ||
| count := 0 | ||
| var seenEntry *SchemaCacheEntry | ||
| cache.Range(func(key [32]byte, value *SchemaCacheEntry) bool { | ||
| count++ | ||
| seenEntry = value | ||
| return true | ||
| }) | ||
|
|
||
| assert.Equal(t, 1, count, "Should only process valid entry") | ||
| assert.Equal(t, validEntry, seenEntry, "Should see the valid entry") | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| package cache | ||
|
|
||
| import "sync" | ||
|
|
||
| // DefaultCache is the default cache implementation using sync.Map for thread-safe concurrent access. | ||
| type DefaultCache struct { | ||
| m *sync.Map | ||
| } | ||
|
|
||
| var _ SchemaCache = &DefaultCache{} | ||
|
|
||
| // NewDefaultCache creates a new DefaultCache with an initialized sync.Map. | ||
| func NewDefaultCache() *DefaultCache { | ||
| return &DefaultCache{m: &sync.Map{}} | ||
| } | ||
|
|
||
| // Load retrieves a schema from the cache. | ||
| func (c *DefaultCache) Load(key [32]byte) (*SchemaCacheEntry, bool) { | ||
| if c == nil || c.m == nil { | ||
| return nil, false | ||
| } | ||
| val, ok := c.m.Load(key) | ||
| if !ok { | ||
| return nil, false | ||
| } | ||
| schemaCache, ok := val.(*SchemaCacheEntry) | ||
| return schemaCache, ok | ||
| } | ||
|
|
||
| // Store saves a schema to the cache. | ||
| func (c *DefaultCache) Store(key [32]byte, value *SchemaCacheEntry) { | ||
| if c == nil || c.m == nil { | ||
| return | ||
| } | ||
| c.m.Store(key, value) | ||
| } | ||
|
|
||
| // Range calls f for each entry in the cache (for testing/inspection). | ||
| func (c *DefaultCache) Range(f func(key [32]byte, value *SchemaCacheEntry) bool) { | ||
| if c == nil || c.m == nil { | ||
| return | ||
| } | ||
| c.m.Range(func(k, v interface{}) bool { | ||
| key, ok := k.([32]byte) | ||
| if !ok { | ||
| return true | ||
| } | ||
| val, ok := v.(*SchemaCacheEntry) | ||
| if !ok { | ||
| return true | ||
| } | ||
| return f(key, val) | ||
| }) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2025 my good sir.