Skip to content

Commit c766056

Browse files
speedstorm1copybara-github
authored andcommitted
fix: Serialize deferred Pydantic model builds to fix a multithreaded first-use race
fixes #2991 PiperOrigin-RevId: 1009072322
1 parent a216d95 commit c766056

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

‎google/genai/_common.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import logging
2424
import re
2525
import sys
26+
import threading
2627
import typing
2728
from typing import Any, Callable, FrozenSet, Optional, Union, get_args, get_origin
2829
import uuid
@@ -597,6 +598,10 @@ def _format_collection(
597598
return f'{brackets[0]}\n' + ',\n'.join(elements) + f',\n{indent}{brackets[1]}'
598599

599600

601+
# Pydantic's model_rebuild() is not thread-safe; defer_build calls it lazily.
602+
_MODEL_BUILD_LOCK = threading.RLock()
603+
604+
600605
class BaseModel(pydantic.BaseModel):
601606

602607
model_config = pydantic.ConfigDict(
@@ -617,6 +622,30 @@ class BaseModel(pydantic.BaseModel):
617622
defer_build=True,
618623
)
619624

625+
@classmethod
626+
def model_rebuild(
627+
cls,
628+
*,
629+
force: bool = False,
630+
raise_errors: bool = True,
631+
_parent_namespace_depth: int = 2,
632+
_types_namespace: Optional[collections.abc.Mapping[str, Any]] = None,
633+
) -> Optional[bool]:
634+
"""Builds the model like Pydantic, but one thread at a time."""
635+
# Pydantic sets `__pydantic_complete__` last, after the validator.
636+
if not force and cls.__pydantic_complete__:
637+
return None
638+
with _MODEL_BUILD_LOCK:
639+
return super().model_rebuild(
640+
force=force,
641+
raise_errors=raise_errors,
642+
# This override adds one frame between the caller and Pydantic.
643+
_parent_namespace_depth=(
644+
_parent_namespace_depth + 1 if _parent_namespace_depth > 0 else 0
645+
),
646+
_types_namespace=_types_namespace,
647+
)
648+
620649
@pydantic.model_validator(mode='before')
621650
@classmethod
622651
def _check_field_type_mismatches(cls, data: Any) -> Any:

‎google/genai/tests/common/test_common.py‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
1616

1717
"""Tests tools in the _common module."""
1818

19+
import collections
20+
import concurrent.futures
1921
from enum import Enum
2022
import inspect
2123
import logging
24+
import sys
2225
import textwrap
26+
import threading
2327
import typing
2428
from typing import List, Optional
2529
import warnings
@@ -1061,3 +1065,128 @@ def test_from_response_prunes_annotated_nested_models():
10611065

10621066
assert result.nested.name == "n"
10631067
assert result.nested_list[0].name == "m"
1068+
1069+
1070+
def _build_from_threads(model_class, num_threads):
1071+
"""Instantiates `model_class` from threads released together."""
1072+
barrier = threading.Barrier(num_threads)
1073+
1074+
def build():
1075+
barrier.wait()
1076+
return model_class(max_output_tokens=1, seed=42).max_output_tokens
1077+
1078+
with concurrent.futures.ThreadPoolExecutor(num_threads) as executor:
1079+
futures = [executor.submit(build) for _ in range(num_threads)]
1080+
errors_seen = [f.exception() for f in futures if f.exception()]
1081+
results = [f.result() for f in futures if not f.exception()]
1082+
return results, errors_seen
1083+
1084+
1085+
def test_concurrent_first_use_does_not_race_model_build(monkeypatch):
1086+
"""Threads that first use a model together build it one at a time."""
1087+
# Unserialized builds of one class can delete each other's validator.
1088+
original = pydantic.BaseModel.__dict__["model_rebuild"].__func__
1089+
threads_inside = collections.Counter()
1090+
counter_lock = threading.Lock()
1091+
max_threads_inside = 0
1092+
first_call = True
1093+
second_thread_entered = threading.Event()
1094+
1095+
def tracking(
1096+
cls,
1097+
*,
1098+
force=False,
1099+
raise_errors=True,
1100+
_parent_namespace_depth=2,
1101+
_types_namespace=None,
1102+
):
1103+
nonlocal max_threads_inside, first_call
1104+
ident = threading.get_ident()
1105+
with counter_lock:
1106+
threads_inside[ident] += 1
1107+
max_threads_inside = max(max_threads_inside, len(threads_inside))
1108+
is_first = first_call
1109+
first_call = False
1110+
if len(threads_inside) > 1:
1111+
second_thread_entered.set()
1112+
try:
1113+
if is_first:
1114+
second_thread_entered.wait(timeout=1.0)
1115+
return original(
1116+
cls,
1117+
force=force,
1118+
raise_errors=raise_errors,
1119+
_parent_namespace_depth=(
1120+
_parent_namespace_depth + 1 if _parent_namespace_depth > 0 else 0
1121+
),
1122+
_types_namespace=_types_namespace,
1123+
)
1124+
finally:
1125+
with counter_lock:
1126+
threads_inside[ident] -= 1
1127+
if not threads_inside[ident]:
1128+
del threads_inside[ident]
1129+
1130+
monkeypatch.setattr(
1131+
pydantic.BaseModel, "model_rebuild", classmethod(tracking)
1132+
)
1133+
1134+
class _FreshConfig(types.GenerateContentConfig):
1135+
pass
1136+
1137+
results, errors_seen = _build_from_threads(_FreshConfig, 8)
1138+
1139+
assert not errors_seen
1140+
assert results == [1] * 8
1141+
assert max_threads_inside == 1
1142+
1143+
1144+
def test_concurrent_first_use_of_fresh_models_succeeds():
1145+
"""Many threads can instantiate a model nothing has built yet.
1146+
1147+
A smoke test only: the race does not reproduce reliably this way, so
1148+
`test_concurrent_first_use_does_not_race_model_build` is the regression test.
1149+
"""
1150+
original_interval = sys.getswitchinterval()
1151+
sys.setswitchinterval(1e-6)
1152+
try:
1153+
for _ in range(3):
1154+
1155+
class _FreshConfig(types.GenerateContentConfig):
1156+
pass
1157+
1158+
results, errors_seen = _build_from_threads(_FreshConfig, 16)
1159+
assert not errors_seen
1160+
assert results == [1] * 16
1161+
finally:
1162+
sys.setswitchinterval(original_interval)
1163+
1164+
1165+
def test_model_rebuild_returns_none_when_built():
1166+
"""A built model skips the rebuild unless `force` is set."""
1167+
1168+
class Model(_common.BaseModel):
1169+
x: int
1170+
1171+
Model(x=1)
1172+
1173+
assert Model.model_rebuild() is None
1174+
assert Model.model_rebuild(force=True)
1175+
assert Model(x=2).x == 2
1176+
1177+
1178+
def test_model_rebuild_resolves_caller_namespace():
1179+
"""`model_rebuild` resolves forward references in the caller's locals.
1180+
1181+
The alias is defined after the class, so only the caller frame has it.
1182+
"""
1183+
1184+
class Model(_common.BaseModel):
1185+
x: "later_int"
1186+
1187+
later_int = int
1188+
1189+
Model.model_rebuild()
1190+
1191+
assert Model.model_fields["x"].annotation is later_int
1192+
assert Model(x=1).x == 1

0 commit comments

Comments
 (0)