Skip to content

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

Merged
bokelley merged 1 commit into
mainfrom
conductor/1694-import-rss-520-mib-9x-10
Oct 29, 2026
Merged

bokelley merged 1 commit into
mainfrom
conductor/1694-import-rss-520-mib-9x-10

Conversation

@bokelley

@bokelley bokelley commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1694

Why

Importing adcp.client, adcp.server, adcp.decisioning or adcp.compat.versions realizes the type surface through adcp.types._eager, and on 9.3.0 that peaked at ~495 MiB RSS (~560 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 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 RootModel subclasses. --base-class AdCPBaseModel puts defer_build=True under every object model, but RootModel[A | B] parametrizes a concrete class at that expression under pydantic's default config and builds there, before the subclass or any config it declares exists. A model_config in the 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.
  • adcp.types.base.AdCPRootModel, a RootModel that parametrizes with defer_build=True; the new post-generate fixer defer_rootmodel_builds points the 96 generated composing roots at it (value roots were already scalar subclasses). isinstance(x, RootModel) and .root are unchanged.
  • 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 9.3.0 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 9.3.0 (before) this PR import time before → after
adcp.types._eager 132 MiB 495 MiB 114 MiB 14.5 s → 4.1 s
adcp.client 174 MiB 561 MiB 175 MiB 15.3 s → 5.2 s
adcp.server – 565 MiB 183 MiB 16.0 s → 6.3 s
adcp.server.test_controller 172 MiB 562 MiB 183 MiB 13.5 s → 5.9 s
adcp.decisioning – 573 MiB 187 MiB 15.3 s → 5.9 s
adcp.compat.versions – 498 MiB 118 MiB 12.0 s → 4.5 s

First-use latency of what was deferred

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 (pytest tests/ -n 6: 18,435 passed, 2,729 skipped; the four failures — two xdist worker crashes under load, the order-dependent test_two_calls_share_server_assigned_context_id, and test_branch_parity_absorbs_registry_variance — all pass in isolation).

A matching PR into next for SDK 10 follows.

🤖 Generated with Claude Code

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 29, 2026

@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 — clean import-time memory fix with no blocking findings.

This PR defers import-time pydantic model builds to cut adcp.client RSS by ~560 MiB (#1694). Key points verified against repo rules:

  • Semver signal correct: fix(types): (not fix!) is right — isinstance(x, RootModel) and .root are preserved, no wire-shape/discriminator/public-API change. No required→optional flips, no enum member removals, no response-shape breaks.
  • Forward-compat intact: _forward_compat.py, aliases.py, _ergonomic.py edits are load-bearing but preserve fallback arms and discriminator keys; no asset-union vocabulary tampering.
  • Generated code: RootModel→AdCPRootModel domain edits are fixer output (via defer_rootmodel_builds), not hand-edits — consistent with the "generated code is not source" rule.
  • CI gates armed: no disabled tests, no blanket # type: ignore, no dropped ruff rules. New test coverage added (tests/test_lazy_rebuild.py +210) pinning the deferred-build contract.
  • Pydantic pinned <2.14 to keep the MockValSer/defer_build placeholder behavior stable.

Decision table: no critical/high/medium findings (rows 1, 4–8 do not fire). gated_paths is false (row 2 n/a). high_risk is true but all three reasons are (modified) with zero medium-or-higher findings — row 3 (deleted) and row 5 (modified + medium) do not fire. No no-auto-approve team match (row 7 n/a). No prior decision. Falls through to row 9 → approve.

…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 9.3.0 that peaked
at ~495 MiB RSS (~560 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(RootModel[A | B])` built the union's validator at the
  RootModel[...] expression, before X or any config it declares existed;
  adagents.AdcpAgentsAuthorization and core.format.CanonicalParameters alone
  cost ~60 MiB. Generated composing roots now parametrize
  adcp.types.base.AdCPRootModel, which carries defer_build=True, via the new
  post-generate fixer defer_rootmodel_builds.

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

  adcp.types._eager            495 -> 114 MiB  (import 14.5 s -> 4.1 s)
  adcp.client                  561 -> 175 MiB
  adcp.server                  565 -> 183 MiB
  adcp.server.test_controller  562 -> 183 MiB
  adcp.decisioning             573 -> 187 MiB
  adcp.compat.versions         498 -> 118 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 (supersedes #1690).

Fixes #1694

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bokelley
bokelley force-pushed the conductor/1694-import-rss-520-mib-9x-10 branch from a980761 to 79ab8ca Compare October 29, 2026 20:08
bokelley added a commit that referenced this pull request Oct 29, 2026
…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.

This PR ships the #1694 import-memory fix: the import-time patchers (_ergonomic, _forward_compat, aliases, canonical_creative) swap model_rebuild(force=True) for base.defer_model_rebuild, and generated composing roots now parametrize the deferred AdCPRootModel instead of pydantic's eager RootModel.

Checks confirmed by the reviewer:

  • No wire-shape or public-API break: AdCPRootModel subclasses RootModel, so adopter isinstance checks hold.
  • No forward-compat union arm or discriminator touched (_forward_compat.py, aliases.py, _ergonomic.py load-bearing semantics intact).
  • canonical_creative.py's __pydantic_init_subclass__ override correctly gains its super() call.
  • build_all_models still realizes newly-deferred roots via its __subclasses__() walk; freezegun path intact.
  • ~120 domain-file edits are pure RootModel→AdCPRootModel churn (codegen churn, not semantic deltas).
  • New tests test_lazy_rebuild.py and test_import_memory.py grade the contract and RSS budget.

Decision: No critical/high/medium findings (row 1 N/A). gated_paths is false (row 2 N/A). high_risk is true but all reasons are (modified) with NO findings — no deletions (row 3 N/A), no medium findings (rows 4/5 N/A). Prior decision was approve, not escalate (row 6 N/A). No no-auto-approve team match (row 7 N/A). Zero medium findings (row 8 N/A). Falls through to row 9 — approve.

@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 — clean pass, no blocking findings.

This is the #1694 import-memory fix: 24 model_rebuild(force=True) calls in the patchers swapped for defer_model_rebuild, and generated composing roots routed through adcp.types.base.AdCPRootModel (defer_build=True). The ~2500-line domains/** churn is defer_rootmodel_builds codegen output (RootModel[ → AdCPRootModel[), not hand edits — paired with the post_generate_fixes.py change and verified consistent.

Checks confirmed clean:

  • No public-surface shape change (AdCPRootModel/defer_model_rebuild stay unexported in adcp.types.base; issubclass(X, RootModel) still True).
  • Serialize-by-inference path builds on instance access rather than raising the old MockValSer TypeError, graded by test_lazy_rebuild.py.
  • __pydantic_init_subclass__ overrides all call super() (canonical_creative.py fixed at L701).
  • No discriminator keys, union arms, or fallback vocabularies touched — forward-compat preserved.
  • No credentials, no external I/O.
  • New 267-line test_lazy_rebuild.py pins the deferred-build and forward-ref contracts.

Decision table: no critical/high/medium findings (row 1 N/A). gated_paths is false (row 2 N/A). high_risk is true but all three reasons are (modified) with zero medium-or-higher findings, so rows 3/5 do not fire. No data-loss/schema/infra findings (row 4 N/A). Prior decision was approve, so row 6 N/A. No no-auto-approve team match (row 7 N/A). Zero medium findings (row 8 N/A). Falls through to row 9 — approve.

@bokelley
bokelley merged commit aac52ca into main Oct 29, 2026
56 of 84 checks passed
@bokelley
bokelley deleted the conductor/1694-import-rss-520-mib-9x-10 branch October 29, 2026 21:16
bokelley added a commit that referenced this pull request Oct 29, 2026
…t 560 MiB RSS (#1713)

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>
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.

Importing adcp.client / adcp.server costs ~520 MiB RSS on 9.x (3.4x 6.6): adcp.types._eager loads the full type surface

1 participant