Skip to content

fix(types): defer the import-time model rebuilds that cost adcp.client 590 MiB RSS - #1713

Merged
bokelley merged 1 commit into
nextfrom
claude/issue-1694-import-rss-next
Oct 29, 2026
Merged

bokelley merged 1 commit into
nextfrom
claude/issue-1694-import-rss-next

Conversation

@bokelley

@bokelley bokelley commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

SDK 10 port of #1704 (merged to main as aac52ca). Fixes #1694 for next.

Why

Importing adcp.client, adcp.server, adcp.decisioning or adcp.compat.versions realizes the type surface through adcp.types._eager, and on 10.0.0-beta.2 that peaked at ~517 MiB RSS (~590 MiB for adcp.client) against ~130 MiB on 6.6. A seller that ran in a 512 MiB container on 6.6 is OOM-killed at startup on 9.3.

Attribution

Per-module self cost of import adcp.types._eager (RSS delta minus children, Linux / CPython 3.12). The growth is entirely 6.6 → 9.0; 9.0 → 9.3 is flat.

module 6.6.0 9.3.0 (main; next matches) what
adcp.types._forward_compat – 133 MiB, 2.4 s 18 × model_rebuild(force=True) on request/response roots
adcp.types._ergonomic 19 MiB 97 MiB, 2.3 s 14 × model_rebuild(force=True)
adcp.types.canonical_creative – 73 MiB, 1.6 s forced rebuild in __pydantic_init_subclass__ on every canonical model
adcp.types.domains.adagents 2 MiB 38 MiB class X(RootModel[A | B]) builds the union at the subscript
adcp.types.domains.core.format 2 MiB 20 MiB same, CanonicalParameters
all 1,088 generated modules (class creation only) ~100 MiB ~115 MiB the floor

Two mechanisms, both building pydantic-core validators at import for models a process may never use:

  1. Forced rebuilds in the patchers. Pydantic does not share the schemas of nested defer_build models between parents, so each model_rebuild(force=True) on a root such as CreateMediaBuyRequest or McpWebhookPayload (which nests every response) walks the whole subtree into a fresh validator and serializer. ~35 of them cost ~300 MiB and ~7 s; the per-rebuild cost grew ~5× between 6.6 and 9.0 as the subtrees (open asset unions, canonical creative) grew, and _forward_compat / canonical_creative added two dozen new ones.
  2. Generated root models. next already points composing roots at adcp.types.base.AdCPRootModel (model_dump emits schema defaults the wire document omitted (143 fields in 60 schemas), which changes digested and echoed bytes #1613, for dump defaults), but that base carried pydantic's default config, so AdCPRootModel[A | B] parametrized a concrete class and built the union's validator at the subscript, before the subclass or any config it declares exists. A model_config in the subclass body is too late.

tracemalloc saw only ~174 MiB of the 9.3 import because the rest is pydantic-core (Rust) validator memory.

What changed

  • adcp.types.base.defer_model_rebuild(model) puts a patched model back into the defer_build placeholder state (pydantic's own MockValSer/MockCoreSchema), so its next validation, serialization or schema request builds from the patched fields. Every model_rebuild(force=True) in _forward_compat, _ergonomic (via its generator), canonical_creative and aliases now calls it. Same outcome, later moment.
  • The existing AdCPRootModel now carries model_config = ConfigDict(defer_build=True), so the parametrized class and every generated root defer. No generated file changes on next: point_rootmodels_at_adcp_root already routes the roots there, so the defer_rootmodel_builds fixer from the main PR is not needed here.
  • Deferring more models exposed a pydantic limitation that already applied to every defer_build model on main: pydantic-core reaches a serializer by reading instance.__pydantic_serializer__ when it dumps by inference (an Any-typed field, serialize_as_any, a plain-pydantic parent), and a placeholder there raised TypeError: 'MockValSer' object is not an instance of 'SchemaSerializer' instead of building — CI caught it on a canonical model that used to be built at import. Every deferred SDK model now carries placeholders that are also descriptors (_DeferredValSer, installed by AdCPBaseModel/AdCPRootModel.__pydantic_init_subclass__ and by defer_model_rebuild), so that read builds the model and returns the real serializer. This also fixes the pre-existing TypeError for an unvalidated or parent-built instance of any generated model dumped through such a parent (TargetingOverlay.model_construct() inside an Any field fails on 10.0.0-beta.2 today). Class-level reads still return the placeholder, which is what pydantic's own isinstance(..., MockValSer) checks expect.
  • Nothing in the generated tree is built at import any more except CreativeManifest (an explicit model_rebuild() in _generated.py, ~3 MiB, left alone) and the hand-written registry models.

Before / after

Peak RSS of a fresh python -I -c "import <module>" (Linux, CPython 3.12; 3.10 and 3.13 are within ±5 MiB):

module 6.6.0 10.0.0-beta.2 (before) this PR import time before → after
adcp.types._eager 132 MiB 517 MiB 121 MiB 13.9 s → 3.6 s
adcp.client 174 MiB 588 MiB 189 MiB 15.6 s → 4.9 s
adcp.server.test_controller 172 MiB 610 MiB 214 MiB 15.0 s → 6.8 s
adcp.decisioning – – 224 MiB –

First-use latency of what was deferred

Measured on main (#1704); the same models are deferred here. The first validation of a deferred root now builds its schema; the second is unchanged.

first use before after
GetProductsRequest.model_validate 58 ms 88 ms
CreateMediaBuyRequest.model_validate 0 ms (built at import) 333 ms
UpdateMediaBuyRequest.model_validate 0 ms 175 ms
GetProductsResponse.model_validate 71 ms 225 ms
GetCreativeDeliveryResponse.model_validate 28 ms 119 ms
GetAdcpCapabilitiesResponse.model_validate 94 ms 107 ms
AdcpAgentsAuthorization.model_validate 0 ms 260 ms
McpWebhookPayload.model_json_schema() 1.35 s 2.4 s
CanonicalParameters.model_json_schema() 0.3 s 0.77 s

A process that has used every one of these sits at ~215 MiB instead of ~520 MiB. build_all_models() still realizes everything eagerly for freezegun callers (#1310, #1339, #1619).

Regression guard

  • tests/test_import_memory.py imports each module above in a python -I subprocess and fails when its peak RSS exceeds 320 MiB (headroom over the 187 MiB high-water mark, well under the 512 MiB the issue is about). It reads VmHWM on Linux because ru_maxrss is inherited across exec from the forking parent, so a probe spawned from a large pytest process would report pytest's peak.
  • tests/test_lazy_rebuild.py grades the deferral contract (an unvalidated, a parent-built and a root-model instance each dump through a plain-pydantic Any field in a fresh process): importing the surface builds none of the patched models, the _forward_compat and _ergonomic patches apply on first use, AdCPRootModel[...] stays deferred, and a canonical subclass may forward-reference a model defined later in its module — which is what fix(types): defer unresolved forward refs in canonical subclasses #1690 fixed by passing raise_errors=False to the forced rebuild; the deferral subsumes it, so that draft can close.

Testing

uv sync, make lint, make typecheck-all, make validate-generated (the fixer is part of the pipeline and the tree reproduces), and the full suite on next (pytest tests/ -n 4: 19,611 passed, 2,731 skipped; the one failure, the order-dependent test_two_calls_share_server_assigned_context_id, passes in isolation).

🤖 Generated with Claude Code

…t 560 MiB RSS

Importing adcp.client, adcp.server, adcp.decisioning or adcp.compat.versions
realizes the type surface through adcp.types._eager, and on 10.0.0-beta.2 that peaked
at ~517 MiB RSS (~590 MiB for adcp.client) against ~130 MiB on 6.6. Two
things built pydantic-core validators at import for models a process may
never use:

- The import-time patchers (_ergonomic, _forward_compat, canonical_creative,
  aliases) edited model_fields and then called model_rebuild(force=True) on
  ~35 request/response roots. Pydantic does not share nested defer_build
  schemas between parents, so each forced rebuild walked the model's whole
  subtree into a fresh validator and serializer: ~300 MiB and ~7 s of the
  import. They now call adcp.types.base.defer_model_rebuild(), which puts the
  model back into the defer_build placeholder state so the patched fields
  take effect on first use. Same outcome, later moment.

- A generated `class X(AdCPRootModel[A | B])` built the union's validator at
  the subscript, before X or any config it declares existed, because the
  #1613 base carried pydantic's default config; adagents.AdcpAgentsAuthorization
  and core.format.CanonicalParameters alone cost ~60 MiB. AdCPRootModel now
  carries defer_build=True, so the parametrized class and X both defer.

Peak RSS of `python -I -c "import <module>"` (Linux, CPython 3.12):

  adcp.types._eager            517 -> 121 MiB  (import 13.9 s -> 3.6 s)
  adcp.client                  588 -> 189 MiB
  adcp.server.test_controller  610 -> 214 MiB
  adcp.decisioning                 -> 224 MiB

The cost moves to first use: the first validation of a deferred root builds
its schema (90-330 ms for the request models, ~2.4 s for McpWebhookPayload's
JSON schema), and a process that has used every one of them sits at ~215 MiB
instead of ~520 MiB. build_all_models() still realizes everything eagerly for
freezegun callers.

Deferring more models exposed a pydantic limitation that already applied to
every defer_build model: pydantic-core reaches a serializer by reading
instance.__pydantic_serializer__ when it dumps by inference (an Any-typed
field, serialize_as_any, a plain-pydantic parent), and a placeholder there
raised TypeError instead of building. Every deferred SDK model now carries
placeholders that are also descriptors (_DeferredValSer), installed by
AdCPBaseModel/AdCPRootModel.__pydantic_init_subclass__ and by
defer_model_rebuild, so that read builds the model and returns the real
serializer. This also fixes the pre-existing TypeError for an unvalidated or
parent-built instance of any generated model dumped through such a parent.

tests/test_import_memory.py imports each entry module in a subprocess and
fails when its peak RSS exceeds 320 MiB; tests/test_lazy_rebuild.py grades
the deferral contract, including that a canonical subclass may forward-
reference a model defined later in its module (the SDK 10 port of #1704; #1690 on main is subsumed).

Fixes #1694 for SDK 10; main carries the same fix as #1704.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — import-time memory fix (#1694, SDK-10 port of #1704) with no blocking findings.

Checked: the four patchers (_ergonomic, _forward_compat, canonical_creative, aliases) swap model_rebuild(force=True) for defer_model_rebuild, and AdCPRootModel gains defer_build=True. Deferral changes when the identical core schema builds, not what it builds — no wire-shape, discriminator, or fallback-arm change, so forward-compat is preserved and the fix(types): semver signal is correct. No removal of fallback arms, no discriminator-key change, no narrowing of published variants.

Serialize-by-inference gap exposed by deferral is closed on both paths (_build_deferred_serializers base.py:939 and _DeferredValSer.__get__). Reliance on pydantic private _mock_val_ser is pinned (pydantic<2.14) and graded by test_lazy_rebuild. Import-memory regression guarded by test_import_memory.py (320 MiB budget). First-use latency increase is an intentional, documented tradeoff a server can warm with build_all_models().

high_risk is true (three modified high-risk files: _ergonomic.py, _forward_compat.py, aliases.py), but per the decision rules the flag is a heuristic to look harder, not an auto-escalate. The reviewer surfaced no medium-or-higher concerns on these modified files, so row 5 does not fire. gated_paths is false; no no-auto-approve team match; no prior decision. Zero findings → falls through to row 9.

@bokelley
bokelley merged commit ab39d63 into next Oct 29, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1694-import-rss-next branch October 29, 2026 23:17
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.

1 participant