Cache compiled serializers across encapsulated contexts#84
Cache compiled serializers across encapsulated contexts#84jagould2012 wants to merge 1 commit intofastify:mainfrom
Conversation
There was a problem hiding this comment.
This breaks the encapsulation.
I think we have few options here:
- stringify the
externalSchemas - add a perfomance option so users that do not have the encapsulation issue can enable it (and a blog post is required)
You have measured the RAM only, could you share the time to start too?
Plus I think the fastify repo will fail with this feature as well, I think/hope this scenario is covered by the tests
const app = require('fastify')({ logger: true })
app.register(async function plugin (app, opts) {
app.addSchema({
$id: 'user',
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
type: { type: 'string' },
}
})
app.get('/a', {
schema: {
response: {
200: {
$ref: 'user#'
}
}
}
}, () => {
return {
id: '123',
type: 'admin',
hello: 'world'
}
})
})
app.register(async function plugin (app, opts) {
app.addSchema({
$id: 'user',
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
type: { type: 'string' },
hello: { type: 'string' },
}
})
app.get('/b', {
schema: {
response: {
200: {
$ref: 'user#'
}
}
}
}, () => {
return {
id: '123',
type: 'admin',
hello: 'world'
}
})
})
async function run () {
const a = await app.inject('/a')
console.log('a', a.json())
const b = await app.inject('/b')
console.log('b', b.json())
}
run()Without the change:
a { id: '123', type: 'admin' }
b { id: '123', type: 'admin', hello: 'world' }
With the change:
a { id: '123', type: 'admin' }
b { id: '123', type: 'admin' }
|
Start time was 81% faster. I included on the other PR fastify/fast-json-stringify#836 As for the encapsulation issue, the PR should only re-use schemas with the same $id, which would always be the same anyway? I don't think fastify will allow you to have different schemas with the same $id in different contexts? The schema registry spans encapsulation already? |
Please, review my snippet above: the
You can't have same |
|
Maybe I'm not understanding the intervals of Fastify. How does one have multiple contexts with different schemas on the same $id? I see your example above, but not sure how to recreate it in a real use of fastify? |
|
And further, is there a way to make the reuse in the PR work just in one context? I have a single plugin loading all my schemas, so I don't really need it to work outside of encapsulation. I just need it to not create hundreds of duplicate serializers inside the one context, which is what is happening now. |
Cache compiled serializers across encapsulated contexts
Problem
SerializerSelectorcreates a newbuildSerializerFactoryper Fastify encapsulated context (plugin). Each factory compiles serializers independently viafast-json-stringify, so routes in different plugins that share the same response schema each trigger a full compilation from scratch.In Fastify applications with encapsulated route plugins (the standard pattern), every plugin gets its own compiler instance. If 45 controllers each define routes returning
{ $ref: "user.json#" }, theuser.jsonschema is compiled into a serializer 45 separate times — producing identical output each time.How it happens
Each
buildSerializerFactoryinvocation returns a freshresponseSchemaCompiler.bind(null, fjsOpts). There is no shared state between factories, so identical schemas are compiled repeatedly.Impact
In a production Fastify service with 45 controllers (271 routes, 90 unique response schemas):
fastJsonStringify()callsready()ready()The 450 → 90 reduction comes from deduplicating identical schemas across encapsulated contexts. Within a single controller, routes like GET /:id, POST, PUT, PATCH that return the same entity schema already share one compilation (5 routes → 2 unique: entity + list wrapper). The cross-controller duplication is what this fix addresses.
Fastify's own TODO acknowledges this
In
fastify/lib/reply.js(lines 382-383):Fix
Move the serializer cache from per-factory scope to
SerializerSelectorscope, so compiled serializers are shared across all encapsulated contexts.function SerializerSelector () { + const cache = new Map() return function buildSerializerFactory (externalSchemas, serializerOpts) { const fjsOpts = Object.assign({}, serializerOpts, { schema: externalSchemas }) - return responseSchemaCompiler.bind(null, fjsOpts) + return function cachedResponseSchemaCompiler (opts) { + const key = JSON.stringify(opts.schema) + const cached = cache.get(key) + if (cached) return cached + const result = responseSchemaCompiler(fjsOpts, opts) + cache.set(key, result) + return result + } } }Why
JSON.stringify(opts.schema)is the right cache key{ $ref: "user.json#" }produce identicalJSON.stringifyoutputexternalSchemas(registered viaaddSchema()) are the same across all contexts in a Fastify instance, so the resolved output is identical regardless of which factory compiled itJSON.stringifyhandles nested objects,$refstrings, andanyOf/oneOfarrays correctlyWhy this is safe
SerializerSelectorinstance, which is the lifetime of the Fastify server. No memory leak concern — the number of unique schemas is bounded and known at startup.addSchema()is called before route registration, and all contexts see the same external schemas.Test Results
All 11 tests pass (10 existing + 1 new cache test). 100% coverage across statements, branches, functions, and lines.
New test: cache hit for identical schemas across factories
Related
$refschemas within a single serializer compilation. That fix reduces per-serializer code size; this fix reduces the number of serializers compiled. Together they address both dimensions of the memory bloat.Reproduction