Skip to content

[Security Solution] Make kbn-openapi-generator producing lazy loaded Zod schemas#264125

Merged
jbudz merged 13 commits intoelastic:mainfrom
maximpn:reduce-baseline-memory-consumption
Apr 22, 2026
Merged

[Security Solution] Make kbn-openapi-generator producing lazy loaded Zod schemas#264125
jbudz merged 13 commits intoelastic:mainfrom
maximpn:reduce-baseline-memory-consumption

Conversation

@maximpn
Copy link
Copy Markdown
Contributor

@maximpn maximpn commented Apr 17, 2026

Summary

Reduces the baseline heap footprint of the many generated Zod schemas (OpenAPI *.gen.ts files) by deferring their construction until first use, and letting the GC reclaim materialized schemas once no consumer is holding on to them. Generated schemas are declared at module-load time but only a subset is actually exercised at runtime, so materializing all of them eagerly wastes memory — and pinning them for the process lifetime wastes memory even for schemas used once.

Changes

  • Add lazySchema(factory) in @kbn/zod/v4 — a Zod-typed wrapper around lazyGCableObject used by generated schemas. Unused schemas stay as a single Proxy + closure; transiently-used schemas are collectible after their last reference is dropped.
  • Update the zod_operation_schema.handlebars template in @kbn/openapi-generator so generated request/response/params/body schemas and named component schemas are wrapped in lazySchema(() => …).

Caveat documented on the helper: instanceof z.ZodType on a lazy schema is false because the Proxy target is an empty object. Zod internals and typical consumers use structural _zod / .def checks rather than instanceof, so this is safe in practice.

Feature flag

Lazy loaded Zod schemas have been smoke tested and there aren't noticeable performance degradations revealed. Anyway disableLazyZodSchemas feature flag has been added to have an ability to disable this optimization. After applying the changes Kibana restart is required. To make the optimization disabled add the following to the Kibana config

feature_flags:
  overrides:
    disableLazyZodSchemas: true

Identify risks

  • Consumers that rely on instanceof z.ZodType / instanceof z.ZodObject against a generated schema will see false. Mitigation: Zod and common helpers use structural checks (_zod, .def) rather than instanceof, and this is called out in the helper JSDoc. Reviewers should flag any instanceof usage against generated schemas.
  • First-access and rebuild cost: the first property access on a previously unused schema materializes it. Because the cache is a WeakRef, if the materialized schema is reclaimed between uses (only possible once the caller has dropped its reference), the next access rebuilds it from the factory — so repeatedly re-materializing the same schema across GC cycles adds overhead. Hot paths should retain a reference (e.g. const s = Schema; s.parse(...)) for the duration of their work.
  • Large surface change in regenerated *.gen.ts files — regeneration is mechanical from the updated handlebars template.

Part of #264170

@maximpn maximpn self-assigned this Apr 17, 2026
@maximpn maximpn added Team:Detections and Resp Security Detection Response Team Team: SecuritySolution Security Solutions Team working on SIEM, Endpoint, Timeline, Resolver, etc. Team:Detection Rule Management Security Detection Rule Management Team ci:build-cloud-image labels Apr 17, 2026
@maximpn maximpn changed the title Reduce baseline memory consumption of generated Zod schemas [Security Solution] Make @kbn/openapi-generator producing lazy loaded Zod schemas Apr 17, 2026
@maximpn maximpn changed the title [Security Solution] Make @kbn/openapi-generator producing lazy loaded Zod schemas [Security Solution] Make kbn-openapi-generator producing lazy loaded Zod schemas Apr 17, 2026
@maximpn maximpn force-pushed the reduce-baseline-memory-consumption branch from 5133728 to 1061bdc Compare April 17, 2026 18:06
@sdesalas
Copy link
Copy Markdown
Member

sdesalas commented Apr 18, 2026

Local testing: Does this improve heap memory.

Seems like there is a visible improvement by lazy loading in my local --dev environment while testing installion of detection rules, though it might just be postponed until the user tries out all the features in kibana.

Note that idle memory consumption after installing prebuilt rules with kibana-puppetter-scripts is almost 100mb lower than in main. Peak memory is reduced but not as much, around 25MB lower, although GC makes it hard to estimate exactly how much this would mean in an instance with lower memory limits. Could be lower even (better) since GC appears less aggressive when more memory is available to the process.

main vs lazy-loading

Two datasets × three runs. Metric: heap_used from kibana-memory-profile-*.csv.

1400 in folder names: runs used NODE_OPTIONS="--max_old_space_size=1400" (V8 old-space limit 1400 MiB).

Method

  • Time window: For each run, the first sample’s timestamp_ms is t₀. Only rows with elapsed ≥ 30 s after t₀ are included (startup/warmup excluded).
  • Per run: peak = max heap_used in that window; average = arithmetic mean of heap_used over included samples.
  • Logs: A quick scan of paired kibana.output.*.txt files found no ERROR / FATAL / OOM-style lines; comparison below is CSV-only.

Dataset: main-1400-round* (baseline)

Run folder Peak heap_used (after 30 s) Avg heap_used (after 30 s) Samples (included / total)
main-1400-round1 1235.66 MiB 1121.35 MiB 555 / 600
main-1400-round2 1257.13 MiB 1100.33 MiB 555 / 600
main-1400-round3 1242.56 MiB 1120.50 MiB 560 / 600

Across 3 runs (mean of per-run values): peak ≈ 1245.1 MiB, average ≈ 1114.1 MiB.

Dataset: lazy-loading-1400-round* (current work)

Run folder Peak heap_used (after 30 s) Avg heap_used (after 30 s) Samples (included / total)
lazy-loading-1400-round1 1219.63 MiB 1060.34 MiB 554 / 600
lazy-loading-1400-round2 1218.85 MiB 1068.25 MiB 555 / 600
lazy-loading-1400-round3 1219.34 MiB 1070.83 MiB 492 / 537

Across 3 runs (mean of per-run values): peak ≈ 1219.3 MiB, average ≈ 1066.5 MiB.

Which set is more memory efficient?

lazy-loading-1400-round* (current work) is more memory efficient on heap_used after the 30 s cutoff:

  • Peak: ~25.8 MiB lower mean peak than main-1400-round* (~2.1% lower).
  • Average: ~47.6 MiB lower mean of per-run averages than main (~4.3% lower).

All six runs show the same ordering: lazy-loading peaks are clustered ~1219 MiB vs main ~1236–1257 MiB; lazy-loading averages are lower in every run.

@maximpn maximpn force-pushed the reduce-baseline-memory-consumption branch 2 times, most recently from 5847d2c to b2b20dc Compare April 20, 2026 08:17
@maximpn maximpn force-pushed the reduce-baseline-memory-consumption branch from 443e01a to 0b9c23c Compare April 20, 2026 09:51
@maximpn maximpn force-pushed the reduce-baseline-memory-consumption branch from cf5b634 to 70bf6c0 Compare April 20, 2026 13:33
@maximpn maximpn requested a review from rudolf April 20, 2026 14:21
@maximpn maximpn marked this pull request as ready for review April 20, 2026 16:46
@maximpn maximpn requested review from a team as code owners April 20, 2026 16:46
@elasticmachine
Copy link
Copy Markdown
Contributor

Pinging @elastic/security-detections-response (Team:Detections and Resp)

@maximpn maximpn requested review from a team as code owners April 20, 2026 16:46
@maximpn maximpn force-pushed the reduce-baseline-memory-consumption branch 2 times, most recently from e0aebd7 to 4f8db49 Compare April 22, 2026 16:12
@maximpn maximpn force-pushed the reduce-baseline-memory-consumption branch from a78fc75 to e8cdbef Compare April 22, 2026 18:33
@elasticmachine
Copy link
Copy Markdown
Contributor

elasticmachine commented Apr 22, 2026

💔 Build Failed

Failed CI Steps

Metrics [docs]

‼️ ERROR: no builds found for mergeBase sha [7c23f84]

History

cc @maximpn

Copy link
Copy Markdown
Contributor

@rudolf rudolf left a comment

Choose a reason for hiding this comment

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

We should remove the unused readme but can be done in a follow-up PR too

@jbudz jbudz merged commit c9fbbf4 into elastic:main Apr 22, 2026
16 of 19 checks passed
@kibanamachine
Copy link
Copy Markdown
Contributor

Starting backport for target branches: 9.4

https://github.com/elastic/kibana/actions/runs/24797125575

@kibanamachine
Copy link
Copy Markdown
Contributor

💔 All backports failed

Status Branch Result
9.4 Backport failed because of merge conflicts

Manual backport

To create the backport manually run:

node scripts/backport --pr 264125

Questions ?

Please refer to the Backport tool documentation

@maximpn
Copy link
Copy Markdown
Contributor Author

maximpn commented Apr 22, 2026

💚 All backports created successfully

Status Branch Result
9.4

Note: Successful backport PRs will be merged automatically after passing CI.

Questions ?

Please refer to the Backport tool documentation

maximpn added a commit that referenced this pull request Apr 22, 2026
…oaded Zod schemas (#264125) (#265139)

# Backport

This will backport the following commits from `main` to `9.4`:
- [[Security Solution] Make kbn-openapi-generator producing lazy loaded
Zod schemas (#264125)](#264125)

<!--- Backport version: 11.0.1 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Maxim
Palenov","email":"maxim.palenov@elastic.co"},"sourceCommit":{"committedDate":"2026-04-22T19:02:58Z","message":"[Security
Solution] Make kbn-openapi-generator producing lazy loaded Zod schemas
(#264125)\n\n## Summary\n\nReduces the baseline heap footprint of the
many generated Zod schemas\n(OpenAPI `*.gen.ts` files) by deferring
their construction until first\nuse, and letting the GC reclaim
materialized schemas once no consumer is\nholding on to them. Generated
schemas are declared at module-load time\nbut only a subset is actually
exercised at runtime, so materializing all\nof them eagerly wastes
memory — and pinning them for the process\nlifetime wastes memory even
for schemas used once.\n\n## Changes\n\n- Add `lazySchema(factory)` in
`@kbn/zod/v4` — a Zod-typed wrapper\naround `lazyGCableObject` used by
generated schemas. Unused schemas stay\nas a single Proxy + closure;
transiently-used schemas are collectible\nafter their last reference is
dropped.\n- Update the `zod_operation_schema.handlebars` template
in\n`@kbn/openapi-generator` so generated
request/response/params/body\nschemas and named component schemas are
wrapped in `lazySchema(() =>\n…)`.\n\nCaveat documented on the helper:
`instanceof z.ZodType` on a lazy schema\nis `false` because the Proxy
target is an empty object. Zod internals\nand typical consumers use
structural `_zod` / `.def` checks rather than\n`instanceof`, so this is
safe in practice.\n\n## Feature flag\n\nLazy loaded Zod schemas have
been smoke tested and there aren't\nnoticeable performance degradations
revealed. Anyway\n`disableLazyZodSchemas` feature flag has been added to
have an ability\nto disable this optimization. After applying the
changes **Kibana\nrestart is required**. To make the optimization
disabled add the\nfollowing to the Kibana
config\n\n```yaml\nfeature_flags:\n overrides:\n disableLazyZodSchemas:
true\n```\n\n### Identify risks\n\n- Consumers that rely on `instanceof
z.ZodType` / `instanceof\nz.ZodObject` against a generated schema will
see `false`. Mitigation:\nZod and common helpers use structural checks
(`_zod`, `.def`) rather\nthan `instanceof`, and this is called out in
the helper JSDoc. Reviewers\nshould flag any `instanceof` usage against
generated schemas.\n- First-access and rebuild cost: the first property
access on a\npreviously unused schema materializes it. Because the cache
is a\n`WeakRef`, if the materialized schema is reclaimed between uses
(only\npossible once the caller has dropped its reference), the next
access\nrebuilds it from the factory — so repeatedly re-materializing
the same\nschema across GC cycles adds overhead. Hot paths should retain
a\nreference (e.g. `const s = Schema; s.parse(...)`) for the duration
of\ntheir work.\n- Large surface change in regenerated `*.gen.ts` files
— regeneration is\nmechanical from the updated handlebars
template.\n\nPart of
https://github.com/elastic/kibana/issues/264170","sha":"c9fbbf4ab88b6ffb4f2fcd00ca1c6ee442e5f827","branchLabelMapping":{"^v9.5.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["performance","release_note:skip","Team:Detections
and Resp","Team: SecuritySolution","Team:Detection Rule
Management","ci:build-cloud-image","backport:version","v9.4.0","v9.5.0"],"title":"[Security
Solution] Make kbn-openapi-generator producing lazy loaded Zod
schemas","number":264125,"url":"https://github.com/elastic/kibana/pull/264125","mergeCommit":{"message":"[Security
Solution] Make kbn-openapi-generator producing lazy loaded Zod schemas
(#264125)\n\n## Summary\n\nReduces the baseline heap footprint of the
many generated Zod schemas\n(OpenAPI `*.gen.ts` files) by deferring
their construction until first\nuse, and letting the GC reclaim
materialized schemas once no consumer is\nholding on to them. Generated
schemas are declared at module-load time\nbut only a subset is actually
exercised at runtime, so materializing all\nof them eagerly wastes
memory — and pinning them for the process\nlifetime wastes memory even
for schemas used once.\n\n## Changes\n\n- Add `lazySchema(factory)` in
`@kbn/zod/v4` — a Zod-typed wrapper\naround `lazyGCableObject` used by
generated schemas. Unused schemas stay\nas a single Proxy + closure;
transiently-used schemas are collectible\nafter their last reference is
dropped.\n- Update the `zod_operation_schema.handlebars` template
in\n`@kbn/openapi-generator` so generated
request/response/params/body\nschemas and named component schemas are
wrapped in `lazySchema(() =>\n…)`.\n\nCaveat documented on the helper:
`instanceof z.ZodType` on a lazy schema\nis `false` because the Proxy
target is an empty object. Zod internals\nand typical consumers use
structural `_zod` / `.def` checks rather than\n`instanceof`, so this is
safe in practice.\n\n## Feature flag\n\nLazy loaded Zod schemas have
been smoke tested and there aren't\nnoticeable performance degradations
revealed. Anyway\n`disableLazyZodSchemas` feature flag has been added to
have an ability\nto disable this optimization. After applying the
changes **Kibana\nrestart is required**. To make the optimization
disabled add the\nfollowing to the Kibana
config\n\n```yaml\nfeature_flags:\n overrides:\n disableLazyZodSchemas:
true\n```\n\n### Identify risks\n\n- Consumers that rely on `instanceof
z.ZodType` / `instanceof\nz.ZodObject` against a generated schema will
see `false`. Mitigation:\nZod and common helpers use structural checks
(`_zod`, `.def`) rather\nthan `instanceof`, and this is called out in
the helper JSDoc. Reviewers\nshould flag any `instanceof` usage against
generated schemas.\n- First-access and rebuild cost: the first property
access on a\npreviously unused schema materializes it. Because the cache
is a\n`WeakRef`, if the materialized schema is reclaimed between uses
(only\npossible once the caller has dropped its reference), the next
access\nrebuilds it from the factory — so repeatedly re-materializing
the same\nschema across GC cycles adds overhead. Hot paths should retain
a\nreference (e.g. `const s = Schema; s.parse(...)`) for the duration
of\ntheir work.\n- Large surface change in regenerated `*.gen.ts` files
— regeneration is\nmechanical from the updated handlebars
template.\n\nPart of
https://github.com/elastic/kibana/issues/264170","sha":"c9fbbf4ab88b6ffb4f2fcd00ca1c6ee442e5f827"}},"sourceBranch":"main","suggestedTargetBranches":["9.4"],"targetPullRequestStates":[{"branch":"9.4","label":"v9.4.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v9.5.0","branchLabelMappingKey":"^v9.5.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/264125","number":264125,"mergeCommit":{"message":"[Security
Solution] Make kbn-openapi-generator producing lazy loaded Zod schemas
(#264125)\n\n## Summary\n\nReduces the baseline heap footprint of the
many generated Zod schemas\n(OpenAPI `*.gen.ts` files) by deferring
their construction until first\nuse, and letting the GC reclaim
materialized schemas once no consumer is\nholding on to them. Generated
schemas are declared at module-load time\nbut only a subset is actually
exercised at runtime, so materializing all\nof them eagerly wastes
memory — and pinning them for the process\nlifetime wastes memory even
for schemas used once.\n\n## Changes\n\n- Add `lazySchema(factory)` in
`@kbn/zod/v4` — a Zod-typed wrapper\naround `lazyGCableObject` used by
generated schemas. Unused schemas stay\nas a single Proxy + closure;
transiently-used schemas are collectible\nafter their last reference is
dropped.\n- Update the `zod_operation_schema.handlebars` template
in\n`@kbn/openapi-generator` so generated
request/response/params/body\nschemas and named component schemas are
wrapped in `lazySchema(() =>\n…)`.\n\nCaveat documented on the helper:
`instanceof z.ZodType` on a lazy schema\nis `false` because the Proxy
target is an empty object. Zod internals\nand typical consumers use
structural `_zod` / `.def` checks rather than\n`instanceof`, so this is
safe in practice.\n\n## Feature flag\n\nLazy loaded Zod schemas have
been smoke tested and there aren't\nnoticeable performance degradations
revealed. Anyway\n`disableLazyZodSchemas` feature flag has been added to
have an ability\nto disable this optimization. After applying the
changes **Kibana\nrestart is required**. To make the optimization
disabled add the\nfollowing to the Kibana
config\n\n```yaml\nfeature_flags:\n overrides:\n disableLazyZodSchemas:
true\n```\n\n### Identify risks\n\n- Consumers that rely on `instanceof
z.ZodType` / `instanceof\nz.ZodObject` against a generated schema will
see `false`. Mitigation:\nZod and common helpers use structural checks
(`_zod`, `.def`) rather\nthan `instanceof`, and this is called out in
the helper JSDoc. Reviewers\nshould flag any `instanceof` usage against
generated schemas.\n- First-access and rebuild cost: the first property
access on a\npreviously unused schema materializes it. Because the cache
is a\n`WeakRef`, if the materialized schema is reclaimed between uses
(only\npossible once the caller has dropped its reference), the next
access\nrebuilds it from the factory — so repeatedly re-materializing
the same\nschema across GC cycles adds overhead. Hot paths should retain
a\nreference (e.g. `const s = Schema; s.parse(...)`) for the duration
of\ntheir work.\n- Large surface change in regenerated `*.gen.ts` files
— regeneration is\nmechanical from the updated handlebars
template.\n\nPart of
https://github.com/elastic/kibana/issues/264170","sha":"c9fbbf4ab88b6ffb4f2fcd00ca1c6ee442e5f827"}}]}]
BACKPORT-->
Copy link
Copy Markdown
Contributor

@rylnd rylnd left a comment

Choose a reason for hiding this comment

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

Looks like this was force-merged for expedience. I was mid-review, so: here are the outstanding comments for if/when we do some followup.

expect(Outer.parse({}).inner).toBeUndefined();
expect(Outer.parse({ inner: { value: 1 } }).inner).toEqual({ value: 1 });
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I noticed that we don't have coverage for .merge() (which is used by allOf), nor for the actual materialization mechanism for lazySchema that would catch regressions to its implementation. Here are some examples of what I'm thinking:

Suggested change
it('forwards .merge() on the underlying object schema', () => {
const Base = lazySchema(() => z.object({ id: z.string() }));
const Merged = Base.merge(z.object({ name: z.string() }));
expect(Merged.parse({ id: 'a', name: 'b' })).toEqual({ id: 'a', name: 'b' });
expect(Merged.safeParse({ id: 'a' }).success).toBe(false);
});
describe('materialization', () => {
it('does not invoke factories for unused schemas', () => {
const factories = Array.from({ length: 20 }, (_, i) =>
jest.fn(() => z.object({ id: z.literal(i) }))
);
const schemas = factories.map((f) => lazySchema(f));
for (const f of factories) {
expect(f).not.toHaveBeenCalled();
}
schemas[3].parse({ id: 3 });
schemas[7].parse({ id: 7 });
expect(factories[3]).toHaveBeenCalledTimes(1);
expect(factories[7]).toHaveBeenCalledTimes(1);
const untouched = factories.filter((_, i) => i !== 3 && i !== 7);
for (const f of untouched) {
expect(f).not.toHaveBeenCalled();
}
});
it('does not materialize a lazy schema used only as a nested reference until the outer schema parses', () => {
const innerFactory = jest.fn(() => z.object({ value: z.number() }));
const Inner = lazySchema(innerFactory);
const Outer = lazySchema(() => z.object({ inner: Inner, tag: z.string() }));
expect(innerFactory).not.toHaveBeenCalled();
Outer.parse({ inner: { value: 1 }, tag: 't' });
expect(innerFactory).toHaveBeenCalledTimes(1);
});
it('does not materialize a lazy schema used in .merge() until the merged schema parses', () => {
const baseFactory = jest.fn(() => z.object({ id: z.string() }));
const Base = lazySchema(baseFactory);
const Merged = lazySchema(() => Base.merge(z.object({ name: z.string() })));
expect(baseFactory).not.toHaveBeenCalled();
Merged.parse({ id: 'a', name: 'b' });
expect(baseFactory).toHaveBeenCalledTimes(1);
});
});

searchSynonyms: 6371
security: 79627
securitySolution: 157027
securitySolution: 172947
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This new limit far exceeds the current count as per the latest build, which is 153.5KB. Should we choose a more conservative value here, or perhaps even keep the current limit as the change doesn't seem needed?

smith pushed a commit to smith/kibana that referenced this pull request Apr 23, 2026
…Zod schemas (elastic#264125)

## Summary

Reduces the baseline heap footprint of the many generated Zod schemas
(OpenAPI `*.gen.ts` files) by deferring their construction until first
use, and letting the GC reclaim materialized schemas once no consumer is
holding on to them. Generated schemas are declared at module-load time
but only a subset is actually exercised at runtime, so materializing all
of them eagerly wastes memory — and pinning them for the process
lifetime wastes memory even for schemas used once.

## Changes

- Add `lazySchema(factory)` in `@kbn/zod/v4` — a Zod-typed wrapper
around `lazyGCableObject` used by generated schemas. Unused schemas stay
as a single Proxy + closure; transiently-used schemas are collectible
after their last reference is dropped.
- Update the `zod_operation_schema.handlebars` template in
`@kbn/openapi-generator` so generated request/response/params/body
schemas and named component schemas are wrapped in `lazySchema(() =>
…)`.

Caveat documented on the helper: `instanceof z.ZodType` on a lazy schema
is `false` because the Proxy target is an empty object. Zod internals
and typical consumers use structural `_zod` / `.def` checks rather than
`instanceof`, so this is safe in practice.

## Feature flag

Lazy loaded Zod schemas have been smoke tested and there aren't
noticeable performance degradations revealed. Anyway
`disableLazyZodSchemas` feature flag has been added to have an ability
to disable this optimization. After applying the changes **Kibana
restart is required**. To make the optimization disabled add the
following to the Kibana config

```yaml
feature_flags:
  overrides:
    disableLazyZodSchemas: true
```

### Identify risks

- Consumers that rely on `instanceof z.ZodType` / `instanceof
z.ZodObject` against a generated schema will see `false`. Mitigation:
Zod and common helpers use structural checks (`_zod`, `.def`) rather
than `instanceof`, and this is called out in the helper JSDoc. Reviewers
should flag any `instanceof` usage against generated schemas.
- First-access and rebuild cost: the first property access on a
previously unused schema materializes it. Because the cache is a
`WeakRef`, if the materialized schema is reclaimed between uses (only
possible once the caller has dropped its reference), the next access
rebuilds it from the factory — so repeatedly re-materializing the same
schema across GC cycles adds overhead. Hot paths should retain a
reference (e.g. `const s = Schema; s.parse(...)`) for the duration of
their work.
- Large surface change in regenerated `*.gen.ts` files — regeneration is
mechanical from the updated handlebars template.

Part of elastic#264170
rbrtj pushed a commit to walterra/kibana that referenced this pull request Apr 27, 2026
…Zod schemas (elastic#264125)

## Summary

Reduces the baseline heap footprint of the many generated Zod schemas
(OpenAPI `*.gen.ts` files) by deferring their construction until first
use, and letting the GC reclaim materialized schemas once no consumer is
holding on to them. Generated schemas are declared at module-load time
but only a subset is actually exercised at runtime, so materializing all
of them eagerly wastes memory — and pinning them for the process
lifetime wastes memory even for schemas used once.

## Changes

- Add `lazySchema(factory)` in `@kbn/zod/v4` — a Zod-typed wrapper
around `lazyGCableObject` used by generated schemas. Unused schemas stay
as a single Proxy + closure; transiently-used schemas are collectible
after their last reference is dropped.
- Update the `zod_operation_schema.handlebars` template in
`@kbn/openapi-generator` so generated request/response/params/body
schemas and named component schemas are wrapped in `lazySchema(() =>
…)`.

Caveat documented on the helper: `instanceof z.ZodType` on a lazy schema
is `false` because the Proxy target is an empty object. Zod internals
and typical consumers use structural `_zod` / `.def` checks rather than
`instanceof`, so this is safe in practice.

## Feature flag

Lazy loaded Zod schemas have been smoke tested and there aren't
noticeable performance degradations revealed. Anyway
`disableLazyZodSchemas` feature flag has been added to have an ability
to disable this optimization. After applying the changes **Kibana
restart is required**. To make the optimization disabled add the
following to the Kibana config

```yaml
feature_flags:
  overrides:
    disableLazyZodSchemas: true
```

### Identify risks

- Consumers that rely on `instanceof z.ZodType` / `instanceof
z.ZodObject` against a generated schema will see `false`. Mitigation:
Zod and common helpers use structural checks (`_zod`, `.def`) rather
than `instanceof`, and this is called out in the helper JSDoc. Reviewers
should flag any `instanceof` usage against generated schemas.
- First-access and rebuild cost: the first property access on a
previously unused schema materializes it. Because the cache is a
`WeakRef`, if the materialized schema is reclaimed between uses (only
possible once the caller has dropped its reference), the next access
rebuilds it from the factory — so repeatedly re-materializing the same
schema across GC cycles adds overhead. Hot paths should retain a
reference (e.g. `const s = Schema; s.parse(...)`) for the duration of
their work.
- Large surface change in regenerated `*.gen.ts` files — regeneration is
mechanical from the updated handlebars template.

Part of elastic#264170
SoniaSanzV pushed a commit to SoniaSanzV/kibana that referenced this pull request Apr 27, 2026
…Zod schemas (elastic#264125)

## Summary

Reduces the baseline heap footprint of the many generated Zod schemas
(OpenAPI `*.gen.ts` files) by deferring their construction until first
use, and letting the GC reclaim materialized schemas once no consumer is
holding on to them. Generated schemas are declared at module-load time
but only a subset is actually exercised at runtime, so materializing all
of them eagerly wastes memory — and pinning them for the process
lifetime wastes memory even for schemas used once.

## Changes

- Add `lazySchema(factory)` in `@kbn/zod/v4` — a Zod-typed wrapper
around `lazyGCableObject` used by generated schemas. Unused schemas stay
as a single Proxy + closure; transiently-used schemas are collectible
after their last reference is dropped.
- Update the `zod_operation_schema.handlebars` template in
`@kbn/openapi-generator` so generated request/response/params/body
schemas and named component schemas are wrapped in `lazySchema(() =>
…)`.

Caveat documented on the helper: `instanceof z.ZodType` on a lazy schema
is `false` because the Proxy target is an empty object. Zod internals
and typical consumers use structural `_zod` / `.def` checks rather than
`instanceof`, so this is safe in practice.

## Feature flag

Lazy loaded Zod schemas have been smoke tested and there aren't
noticeable performance degradations revealed. Anyway
`disableLazyZodSchemas` feature flag has been added to have an ability
to disable this optimization. After applying the changes **Kibana
restart is required**. To make the optimization disabled add the
following to the Kibana config

```yaml
feature_flags:
  overrides:
    disableLazyZodSchemas: true
```

### Identify risks

- Consumers that rely on `instanceof z.ZodType` / `instanceof
z.ZodObject` against a generated schema will see `false`. Mitigation:
Zod and common helpers use structural checks (`_zod`, `.def`) rather
than `instanceof`, and this is called out in the helper JSDoc. Reviewers
should flag any `instanceof` usage against generated schemas.
- First-access and rebuild cost: the first property access on a
previously unused schema materializes it. Because the cache is a
`WeakRef`, if the materialized schema is reclaimed between uses (only
possible once the caller has dropped its reference), the next access
rebuilds it from the factory — so repeatedly re-materializing the same
schema across GC cycles adds overhead. Hot paths should retain a
reference (e.g. `const s = Schema; s.parse(...)`) for the duration of
their work.
- Large surface change in regenerated `*.gen.ts` files — regeneration is
mechanical from the updated handlebars template.

Part of elastic#264170
spong added a commit that referenced this pull request May 1, 2026
…types (#267326)

### Summary

Cleanup pass on `@kbn/evals-common` and the `evals` plugin, mirroring
what was done for `@kbn/inbox-common` in #265634. No functional changes
for end users, but the OAS docs generated from these routes are now
correct, and the in-memory cost of the Zod schemas drops via the
upstream lazy-bind optimization.

### Changes

- **Switch to canonical `buildRouteValidationWithZod` from
`@kbn/zod-helpers/v4`**
- Deleted the local copy in `kbn-evals-common/impl/schemas/common.ts`
and its re-export from `kbn-evals-common/index.ts`.
- Updated all 18 evals route files to import the helper from
`@kbn/zod-helpers/v4`.
- Pruned `@kbn/zod-helpers` and `@kbn/core` from `kbn-evals-common`'s
`moon.yml` / `tsconfig.json`; added `@kbn/zod-helpers` to the `evals`
plugin's instead.
- The canonical helper attaches `_sourceSchema` to the returned
validator (added in #263354), which `kbn-router-to-openapispec` unwraps
so route `params` / `query` / `body` actually appear in generated OAS
docs. The local copy did not, so any OAS doc generation for these routes
was silently dropping schema info.

- **Regenerated OAS types** (`yarn openapi:generate` in
`kbn-evals-common`)
- Picks up the `lazySchema(() => …)` wrappers introduced by #264125 and
tuned in #266343 (upstream `colinhacks/zod#5897` lazy-bind memory
optimization). 19 `.gen.ts` files updated; pattern matches the prior
inbox regen exactly.

- **README cleanup** — pointed at the new helper location and dropped
the obsolete "after regenerating, you may need to fix unused imports"
workaround (no longer needed thanks to the OAS-generator `fix_eslint.ts`
fix from #265634, which forces the non-editor branch when invoked from
agent/IDE terminals).


_PR developed with Cursor + Claude Opus 4.7 Super Duper xHigh
Thinking++_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:version Backport to applied version labels ci:build-cloud-image performance release_note:skip Skip the PR/issue when compiling release notes Team:Detection Rule Management Security Detection Rule Management Team Team:Detections and Resp Security Detection Response Team Team: SecuritySolution Security Solutions Team working on SIEM, Endpoint, Timeline, Resolver, etc. v9.4.0 v9.5.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants