|
16 | 16 |
|
17 | 17 | """Tests tools in the _common module.""" |
18 | 18 |
|
| 19 | +import collections |
| 20 | +import concurrent.futures |
19 | 21 | from enum import Enum |
20 | 22 | import inspect |
21 | 23 | import logging |
| 24 | +import sys |
22 | 25 | import textwrap |
| 26 | +import threading |
23 | 27 | import typing |
24 | 28 | from typing import List, Optional |
25 | 29 | import warnings |
@@ -1061,3 +1065,128 @@ def test_from_response_prunes_annotated_nested_models(): |
1061 | 1065 |
|
1062 | 1066 | assert result.nested.name == "n" |
1063 | 1067 | 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