Repository navigation
fix(types): defer the import-time model rebuilds that cost adcp.client 590 MiB RSS - #1713
Conversation
…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 — 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.
SDK 10 port of #1704 (merged to main as aac52ca). Fixes #1694 for
next.Why
Importing
adcp.client,adcp.server,adcp.decisioningoradcp.compat.versionsrealizes the type surface throughadcp.types._eager, and on 10.0.0-beta.2 that peaked at ~517 MiB RSS (~590 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.nextalready points composing roots atadcp.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, soAdCPRootModel[A | B]parametrized a concrete class and built the union's validator at the subscript, before the subclass or any config it declares exists. Amodel_configin the subclass 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.AdCPRootModelnow carriesmodel_config = ConfigDict(defer_build=True), so the parametrized class and every generated root defer. No generated file changes onnext:point_rootmodels_at_adcp_rootalready routes the roots there, so thedefer_rootmodel_buildsfixer from the main PR is not needed here.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 10.0.0-beta.2 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.server.test_controlleradcp.decisioningFirst-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.
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 onnext(pytest tests/ -n 4: 19,611 passed, 2,731 skipped; the one failure, the order-dependenttest_two_calls_share_server_assigned_context_id, passes in isolation).🤖 Generated with Claude Code