Repository navigation
fix(types): defer the import-time model rebuilds that cost adcp.client 560 MiB RSS - #1704
Conversation
There was a problem hiding this comment.
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):(notfix!) is right —isinstance(x, RootModel)and.rootare 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.pyedits 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.14to 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>
a980761 to
79ab8ca
Compare
…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>
There was a problem hiding this comment.
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:
AdCPRootModelsubclassesRootModel, so adopterisinstancechecks hold. - No forward-compat union arm or discriminator touched (
_forward_compat.py,aliases.py,_ergonomic.pyload-bearing semantics intact). canonical_creative.py's__pydantic_init_subclass__override correctly gains itssuper()call.build_all_modelsstill realizes newly-deferred roots via its__subclasses__()walk; freezegun path intact.- ~120 domain-file edits are pure
RootModel→AdCPRootModelchurn (codegen churn, not semantic deltas). - New tests
test_lazy_rebuild.pyandtest_import_memory.pygrade 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.
There was a problem hiding this comment.
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.
…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>
Fixes #1694
Why
Importing
adcp.client,adcp.server,adcp.decisioningoradcp.compat.versionsrealizes the type surface throughadcp.types._eager, and on 9.3.0 that peaked at ~495 MiB RSS (~560 MiB foradcp.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.adcp.types._forward_compatmodel_rebuild(force=True)on request/response rootsadcp.types._ergonomicmodel_rebuild(force=True)adcp.types.canonical_creative__pydantic_init_subclass__on every canonical modeladcp.types.domains.adagentsclass X(RootModel[A | B])builds the union at the subscriptadcp.types.domains.core.formatCanonicalParametersTwo mechanisms, both building pydantic-core validators at import for models a process may never use:
defer_buildmodels between parents, so eachmodel_rebuild(force=True)on a root such asCreateMediaBuyRequestorMcpWebhookPayload(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_creativeadded two dozen new ones.RootModelsubclasses.--base-class AdCPBaseModelputsdefer_build=Trueunder every object model, butRootModel[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. Amodel_configin the body is too late.tracemallocsaw 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 thedefer_buildplaceholder state (pydantic's ownMockValSer/MockCoreSchema), so its next validation, serialization or schema request builds from the patched fields. Everymodel_rebuild(force=True)in_forward_compat,_ergonomic(via its generator),canonical_creativeandaliasesnow calls it. Same outcome, later moment.adcp.types.base.AdCPRootModel, aRootModelthat parametrizes withdefer_build=True; the new post-generate fixerdefer_rootmodel_buildspoints the 96 generated composing roots at it (value roots were already scalar subclasses).isinstance(x, RootModel)and.rootare unchanged.defer_buildmodel on main: pydantic-core reaches a serializer by readinginstance.__pydantic_serializer__when it dumps by inference (anAny-typed field,serialize_as_any, a plain-pydantic parent), and a placeholder there raisedTypeError: '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 byAdCPBaseModel/AdCPRootModel.__pydantic_init_subclass__and bydefer_model_rebuild), so that read builds the model and returns the real serializer. This also fixes the pre-existingTypeErrorfor an unvalidated or parent-built instance of any generated model dumped through such a parent (TargetingOverlay.model_construct()inside anAnyfield fails on 9.3.0 today). Class-level reads still return the placeholder, which is what pydantic's ownisinstance(..., MockValSer)checks expect.CreativeManifest(an explicitmodel_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):adcp.types._eageradcp.clientadcp.serveradcp.server.test_controlleradcp.decisioningadcp.compat.versionsFirst-use latency of what was deferred
The first validation of a deferred root now builds its schema; the second is unchanged.
GetProductsRequest.model_validateCreateMediaBuyRequest.model_validateUpdateMediaBuyRequest.model_validateGetProductsResponse.model_validateGetCreativeDeliveryResponse.model_validateGetAdcpCapabilitiesResponse.model_validateAdcpAgentsAuthorization.model_validateMcpWebhookPayload.model_json_schema()CanonicalParameters.model_json_schema()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.pyimports each module above in apython -Isubprocess 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 readsVmHWMon Linux becauseru_maxrssis 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.pygrades the deferral contract (an unvalidated, a parent-built and a root-model instance each dump through a plain-pydanticAnyfield in a fresh process): importing the surface builds none of the patched models, the_forward_compatand_ergonomicpatches 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 passingraise_errors=Falseto 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-dependenttest_two_calls_share_server_assigned_context_id, andtest_branch_parity_absorbs_registry_variance— all pass in isolation).A matching PR into
nextfor SDK 10 follows.🤖 Generated with Claude Code