Skip to content

Commit 0719f1e

Browse files
authored
fix(proto-plus): make Marshal thread-safe and handle race conditions (#17774)
# Thread-safe Marshal Initialization ## Problem A RuntimeError saying `dictionary changed size during iteration` can occur randomly in `BaseMarshal.get_rule`. This happens because one thread is reading the `_instances` dictionary while another thread is adding a new instance to it. This is common when using features like Firestore's on_snapshot in a background thread. ## Solution 1. **Thread-safe instance creation:** Applied a locking mechanism in `Marshal.__new__`. It uses a technique called "double-checked locking" to make sure only one thread creates a new instance at a time without slowing down normal reads. 2. **Copy-on-Write Pattern:** When a new instance is added, we make a copy of the existing instances dictionary, add the new one, and then replace the dictionary atomically. This ensures that threads iterating over the old dictionary are not interrupted. 3. **Defensive Attribute Access:** Added safety in `BaseMarshal.get_rule` to avoid trying to read rules from an instance that has been registered but has not finished initializing yet. ## Notes to Reviewers - The Copy-on-Write pattern allows us to avoid locking during reads (which are very common), keeping performance high while fixing the race condition. - A new test file `test_marshal_thread_safety.py` has been added to cover these concurrency scenarios. - Some portions of files in this package were reformatted automatically by the code linters. Fixes #15100
1 parent d461da7 commit 0719f1e

2 files changed

Lines changed: 85 additions & 16 deletions

File tree

packages/proto-plus/proto/marshal/marshal.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,20 @@
1313
# limitations under the License.
1414

1515
import abc
16+
import threading
1617

17-
from google.protobuf import duration_pb2
18-
from google.protobuf import timestamp_pb2
19-
from google.protobuf import field_mask_pb2
20-
from google.protobuf import struct_pb2
21-
from google.protobuf import wrappers_pb2
18+
from google.protobuf import (
19+
duration_pb2,
20+
field_mask_pb2,
21+
struct_pb2,
22+
timestamp_pb2,
23+
wrappers_pb2,
24+
)
2225

2326
from proto.marshal import compat
24-
from proto.marshal.collections import MapComposite
25-
from proto.marshal.collections import Repeated
26-
from proto.marshal.collections import RepeatedComposite
27-
27+
from proto.marshal.collections import MapComposite, Repeated, RepeatedComposite
2828
from proto.marshal.rules import bytes as pb_bytes
29-
from proto.marshal.rules import stringy_numbers
30-
from proto.marshal.rules import dates
31-
from proto.marshal.rules import struct
32-
from proto.marshal.rules import wrappers
33-
from proto.marshal.rules import field_mask
29+
from proto.marshal.rules import dates, field_mask, stringy_numbers, struct, wrappers
3430
from proto.primitives import ProtoType
3531

3632

@@ -168,7 +164,10 @@ def get_rule(self, proto_type):
168164
# See https://github.com/googleapis/proto-plus-python/issues/349
169165
if rule == self._noop and hasattr(self, "_instances"):
170166
for _, instance in self._instances.items():
171-
rule = instance._rules.get(proto_type, self._noop)
167+
# Avoid race condition where instance is added to _instances
168+
# but __init__ hasn't run yet.
169+
rules = getattr(instance, "_rules", {})
170+
rule = rules.get(proto_type, self._noop)
172171
if rule != self._noop:
173172
break
174173
return rule
@@ -254,6 +253,7 @@ class Marshal(BaseMarshal):
254253
"""
255254

256255
_instances = {}
256+
_instance_creation_lock = threading.Lock()
257257

258258
def __new__(cls, *, name: str):
259259
"""Create a marshal instance.
@@ -265,7 +265,18 @@ def __new__(cls, *, name: str):
265265
"""
266266
klass = cls._instances.get(name)
267267
if klass is None:
268-
klass = cls._instances[name] = super().__new__(cls)
268+
with cls._instance_creation_lock:
269+
# Double check inside lock to confirm another thread hasn't
270+
# created the instance while we were waiting for the lock.
271+
klass = cls._instances.get(name)
272+
if klass is None:
273+
# Use Copy-on-Write to avoid 'RuntimeError: dictionary changed size during iteration'
274+
# in BaseMarshal.get_rule. This allows other threads to iterate over the old
275+
# dictionary safely while we replace it with a new one atomically.
276+
new_instances = cls._instances.copy()
277+
klass = super().__new__(cls)
278+
new_instances[name] = klass
279+
cls._instances = new_instances
269280

270281
return klass
271282

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from unittest.mock import patch
2+
3+
from proto.marshal.marshal import Marshal
4+
5+
6+
def test_marshal_identity():
7+
m1 = Marshal(name="foo")
8+
m2 = Marshal(name="foo")
9+
assert m1 is m2
10+
11+
12+
def test_marshal_different_names():
13+
m1 = Marshal(name="foo")
14+
m2 = Marshal(name="bar")
15+
assert m1 is not m2
16+
17+
18+
def test_marshal_new_race_condition():
19+
# Test the case where klass is None at line 266,
20+
# but NOT None at line 271 (another thread created it).
21+
22+
from unittest.mock import MagicMock
23+
24+
mock_instances = MagicMock()
25+
26+
call_count = 0
27+
28+
def get_side_effect(name, default=None):
29+
nonlocal call_count
30+
call_count += 1
31+
if call_count == 1:
32+
return None # First check returns None
33+
# Simulate another thread having created it
34+
return "fake_instance"
35+
36+
mock_instances.get.side_effect = get_side_effect
37+
38+
with patch.object(Marshal, "_instances", mock_instances):
39+
instance = Marshal(name="race_test")
40+
assert instance == "fake_instance"
41+
42+
43+
def test_get_rule_uninitialized_instance():
44+
class FakeMarshal:
45+
# No _rules attribute
46+
pass
47+
48+
m = Marshal(name="default")
49+
50+
# Inject FakeMarshal into Marshal._instances safely using patch.dict
51+
with patch.dict(Marshal._instances, {"fake_uninitialized": FakeMarshal()}):
52+
53+
class DummyType:
54+
pass
55+
56+
# This should not raise AttributeError because of getattr safety
57+
rule = m.get_rule(DummyType)
58+
assert rule == m._noop

0 commit comments

Comments
 (0)