Skip to content

Conversation

its-hammer-time
Copy link
Contributor

@its-hammer-time its-hammer-time commented Oct 7, 2025

Oct 14th 2025 Update

TL;DR -

  1. The "global" validator now takes in an option for a SchemaCache interface. If one isn't provided then we will use the local sync.Map by default. This allows a developer to pass in nil if they want to disable caching all together or they can pass in their own implementation that matches the interface if they have a better alternative
    • Given the cache is now on the "global" validator, I've removed the per-section caches
  2. Updated the Validate[Request|Response]Schema functions so they take in a struct rather than a series of arguments. This will allow us to add more in the future if needed without being a breaking change.
  3. Simplified the Validate[Request|Response]Schema functions so that they no longer take in the renderedSchema and jsonSchema arguments. Instead, a user passes in the *base.Schema they got from libopenapi and the function now handles rendering those objects for them.
    • This also means we can remove the logic this library was doing before calling these functions

With the latest commit, I merged the version changes that were pushed to main as well as swapped over to the struct input we discussed earlier. Additionally, I've tried to simplify the client interface by removing the jsonSchema and renderedSchema arguments that were previously present on the Validate[Request|Response]Schema functions. Instead, the client just needs to pass in their *base.Schema object and the function will handle generating those for them. As a result, the logic that previously did this in the validate_body.go files has been removed as well.

The caveat of this approach is that some of your unit tests were passing in manually crafted *base.Schema objects. These didn't have the low field populated which meant we couldn't render the schema from them. In order to get the unit tests working, I had to generate the schemas from the libopenapi package which requires taking in a complete OpenAPI spec. In my opinion, this is a more accurate experience as developers using libopenapi-validator should be using libopenapi as you pass the doc to create the validator, but I wanted to callout that this was now a requirement.

Overall though, the input struct now accepts:

  1. The request/response
  2. The *base.Schema to validate against
  3. An optional cache. If no cache is provided, we'll render everything from scratch every time.

If you'd prefer I "revert" the changes and leave it so the function still takes in the rendered schemas, I can do that. Just thought this might be a nice refactor to simplify the interface if someone is using this function directly. It also makes it so we only need to interact with the cache in one spot versus two like my PR had before.

Warning: This is a breaking change

Updated: Refactored ValidateRequestSchema and ValidateResponseSchema to use a struct-based API to prevent future breaking changes. The functions now take a single struct parameter with functional options support.

TL;DR

What: Pre-compile and cache JSONSchemas instead of recompiling on every request.

Why: The library was creating ~100KB of garbage per request by recompiling schemas, causing high memory pressure and frequent GC pauses.

Impact: 6-9x faster validation, 90% less memory, 11x fewer GC cycles.

Breaking Change: ValidateRequestSchema/ValidateResponseSchema now use struct-based API (future-proof for new parameters).

Trade-off: Validator creation 2x slower (8ms vs 4ms), paid back after 2 requests.

Note: High-level Validator interface unchanged - only affects direct function callers.


Add compiled schema caching for 6-9x faster validation

Problem

Every request was compiling JSONSchemas from scratch, creating ~96KB of garbage per request and triggering GC every 22 requests. This caused high memory pressure, frequent GC pauses, and unpredictable tail latencies.

Solution

Pre-compile all JSONSchemas during validator initialization and reuse them across requests. The cache is eagerly warmed by walking the entire OpenAPI document.

Performance Impact

Metric Before After Improvement
Request Body Validation 121µs, 107KB 18.8µs, 11KB 6.5x faster, 90% less memory
Response Body Validation 111µs, 122KB 17.6µs, 26KB 6.3x faster, 78% less memory
Full Request/Response 225µs, 207KB 25.6µs, 15KB 8.8x faster, 93% less memory
GC Frequency 22 req/GC 257 req/GC 11.7x improvement

At 1000 req/sec: 102 MB/sec → 8.3 MB/sec allocated (10x reduction!)

Trade-offs

Pros:

  • 6-9x faster validation
  • 90% less per-request memory
  • 11.7x fewer GC cycles (better tail latencies)
  • Scales excellently with request volume

Cons:

  • Validator creation 2x slower (4ms → 8.4ms)
  • Initial memory 2.2x larger (3.2MB → 7.1MB)
  • Both are one-time costs paid back after ~2 requests

Implementation

Cache Structure

Added helpers.SchemaCache storing rendered YAML, JSON, and compiled schema.

Cache Warming

On initialization, walks all paths → operations → request/response bodies → parameters, pre-compiling each schema.

Runtime

  1. Look up schema by hash
  2. If cached: use pre-compiled schema (no allocation!)
  3. If not: compile, cache, use
  4. Even on failure: cache rendered data to avoid re-rendering

Breaking Changes

⚠️ Refactored to struct-based API:

// Before
ValidateRequestSchema(request, schema, renderedSchema, jsonSchema, 3.1, opts...)

// After  
ValidateRequestSchema(&ValidateRequestSchemaInput{
    Request: request,
    Schema:  schema,
    Version: 3.1,
    Options: []config.Option{}, // optional functional options
})

Why: Prevents future breaking changes when adding new parameters. Aligns with Go best practices for extensible APIs.

Migration:

  • Wrap parameters in the new struct
  • Schema rendering and compilation now handled internally (remove renderedSchema, jsonSchema, compiledSchema args)
  • Options use functional pattern: pass []config.Option{} or use config.WithExistingOpts(opts) to convert existing *ValidationOptions

Note: Most users use the Validator interface (ValidateHttpRequest, ValidateHttpResponse), which are unchanged and fully backward compatible.

Testing

  • ✅ All 200+ existing tests pass
  • ✅ Added 10 new cache warming tests
  • Coverage: 96.6% (requests: 100%, responses: 100%, parameters: 99%)
  • ✅ Comprehensive benchmarks included

When to Use

Ideal for:

  • Long-lived validators (web servers, API gateways)
  • High request volumes (>100 req/sec)
  • Production environments

Less ideal for:

  • Short-lived validators (CLI tools, one-off validations)
  • Very low request volumes

For typical production use cases, this is a massive win.

Key Changes

API Refactor:

  • requests/validate_request.go - Struct-based API with internal schema rendering/compilation
  • responses/validate_response.go - Struct-based API with internal schema rendering/compilation
  • requests/validate_body.go - Updated to use new struct API
  • responses/validate_body.go - Updated to use new struct API

Caching:

  • validator.go - Eager cache warming (uses GetOperations() for completeness)
  • helpers/schema_compiler.go - SchemaCache type & shared SchemaCacheInterface
  • config/config.go - Default cache initialization via NewValidationOptions()

Tests:

  • Updated all tests to use struct-based API
  • Added cache population verification tests
  • All 200+ tests pass with identical assertions

@codecov
Copy link

codecov bot commented Oct 7, 2025

Codecov Report

❌ Patch coverage is 99.38080% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (69c8af1) to head (ba05c7a).
⚠️ Report is 16 commits behind head on main.

Files with missing lines Patch % Lines
validator.go 98.19% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #187      +/-   ##
==========================================
+ Coverage   97.09%   97.21%   +0.12%     
==========================================
  Files          41       42       +1     
  Lines        4403     4597     +194     
==========================================
+ Hits         4275     4469     +194     
  Misses        128      128              
Flag Coverage Δ
unittests 97.21% <99.38%> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@its-hammer-time its-hammer-time marked this pull request as ready for review October 7, 2025 22:38
@daveshanley
Copy link
Member

I just want to say:

Request Body Validation 121µs, 107KB 18.8µs, 11KB 6.5x faster, 90% less memory
Response Body Validation 111µs, 122KB 17.6µs, 26KB 6.3x faster, 78% less memory
Full Request/Response 225µs, 207KB 25.6µs, 15KB 8.8x faster, 93% less memory
GC Frequency 22 req/GC 257 req/GC 11.7x improvement

This is fantastic.

Thank you!

@its-hammer-time its-hammer-time force-pushed the reduce-memory-pressure-from-request-time-schema-compilation branch from 4c50f83 to 8076910 Compare October 14, 2025 17:36
description: This number starts its journey where most numbers are too scared to begin!
exclusiveMinimum: true
minimum: 10`,
version: 3.0,
Copy link
Contributor Author

@its-hammer-time its-hammer-time Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what was happening before, but exclusiveMinimum being true is an OpenAPI 3.0 feature and isn't valid in 3.1 (which I imagine is why you have these unit tests). Just updated it to pass in the correct version.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@k1LoW added a new version argument to set the correct version, this must be an artifact of that.

cache/cache.go Outdated
@@ -0,0 +1,26 @@
// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2025 my good sir.

Copy link
Member

@daveshanley daveshanley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix the dates and this looks good.

@@ -0,0 +1,308 @@
// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2025

description: This number starts its journey where most numbers are too scared to begin!
exclusiveMinimum: true
minimum: 10`,
version: 3.0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@k1LoW added a new version argument to set the correct version, this must be an artifact of that.

@daveshanley daveshanley merged commit 5ace66c into pb33f:main Oct 22, 2025
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants