This is an automated email from the ASF dual-hosted git repository.

tvalentyn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 8eda31b281f [Python] Fix max_models_per_worker_hint not being enforced 
in KeyedModelHandler (#40500)
8eda31b281f is described below

commit 8eda31b281f292854e83e3f6a3d84218007eacfb
Author: Divyansh Yadav <[email protected]>
AuthorDate: Sat Oct 17 02:47:40 2026 +0530

    [Python] Fix max_models_per_worker_hint not being enforced in 
KeyedModelHandler (#40500)
    
    * [Python] Fix max_models_per_worker_hint not being enforced in 
KeyedModelHandler
    
    * Guard increment_max_models with a lock on the model handler manager
---
 CHANGES.md                                        |  1 +
 sdks/python/apache_beam/ml/inference/base.py      | 31 ++++++++++++++------
 sdks/python/apache_beam/ml/inference/base_test.py | 35 +++++++++++++++++++++++
 3 files changed, 59 insertions(+), 8 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 537c3a32447..d9ca6f0b83d 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -104,6 +104,7 @@
 * (Go) Fixed the harness leaking Data/State gRPC streams after the worker 
stops, and a deadlock when Send returns EOF 
([#40260](https://github.com/apache/beam/issues/40260)).
 * (Go) Wrap last dataWriter/timerWriter send errors on Close 
([#40405](https://github.com/apache/beam/issues/40405)).
 * (Go) Fixed a deadlock recreating a data channel while holding the channel 
lock ([#40414](https://github.com/apache/beam/issues/40414)).
+* (Python) Fixed `KeyedModelHandler` not enforcing 
`max_models_per_worker_hint` when several copies of the model handler run in 
the same SDK process ([#40468](https://github.com/apache/beam/issues/40468)).
 * (Java) Fixed the declared schema of the error output of the Kafka write 
SchemaTransform, which wrapped the error schema a second time and did not match 
the rows it emits ([#39760](https://github.com/apache/beam/issues/39760)).
 * (Go) Fixed pubsubio importing a `google.golang.org/genproto` package removed 
in recent releases, which broke builds of Go modules depending on a current 
`genproto` version ([#40018](https://github.com/apache/beam/issues/40018)).
 * (Python) `VertexAIFeatureStoreEnrichmentHandler` now logs a warning instead 
of failing when the caller lacks permission to read the feature store metadata 
([#40442](https://github.com/apache/beam/issues/40442)).
diff --git a/sdks/python/apache_beam/ml/inference/base.py 
b/sdks/python/apache_beam/ml/inference/base.py
index 823a4a3fa71..ce014e4b04f 100644
--- a/sdks/python/apache_beam/ml/inference/base.py
+++ b/sdks/python/apache_beam/ml/inference/base.py
@@ -577,6 +577,11 @@ class _ModelHandlerManager:
         model.
     """
     self._max_models = None
+    # Process ids that have already contributed to _max_models
+    self._incremented_process_ids: set[int] = set()
+    # Guards _max_models and _incremented_process_ids, since the shared
+    # manager serves calls from several processes on separate threads.
+    self._max_models_lock = threading.Lock()
     # Map keys to model handlers
     self._mh_map: dict[str, ModelHandler] = mh_map
     # Map keys to the last updated model path for that key
@@ -631,17 +636,26 @@ class _ModelHandlerManager:
     return _ModelLoadStats(
         tag, end_time - start_time, memory_after - memory_before)
 
-  def increment_max_models(self, increment: int):
+  def increment_max_models(
+      self, increment: int, process_id: Optional[int] = None):
     """
     Increments the number of models that this instance of a
     _ModelHandlerManager is able to hold. If it is never called,
     no limit is imposed.
     Args:
       increment: the amount by which we are incrementing the number of models.
+      process_id: the id of the process requesting the increment. If set,
+        only the first increment from each process is applied, since many
+        copies of the model handler can share this manager within a process.
     """
-    if self._max_models is None:
-      self._max_models = 0
-    self._max_models += increment
+    with self._max_models_lock:
+      if process_id is not None:
+        if process_id in self._incremented_process_ids:
+          return
+        self._incremented_process_ids.add(process_id)
+      if self._max_models is None:
+        self._max_models = 0
+      self._max_models += increment
 
   def update_model_handler(self, key: str, model_path: str, previous_key: str):
     """
@@ -867,11 +881,12 @@ class KeyedModelHandler(Generic[KeyT, ExampleT, 
PredictionT, ModelT],
           self._unkeyed.run_inference(unkeyed_batch, model, inference_args))
 
     # The first time a MultiProcessShared ModelManager is used for inference
-    # from this process, we should increment its max model count
+    # from this process, we should increment its max model count. The manager
+    # may live in another process and be shared by several deserialized copies
+    # of this handler, so it dedupes increments by process id.
     if self._max_models_per_worker_hint is not None:
-      lock = threading.Lock()
-      if lock.acquire(blocking=False):
-        model.increment_max_models(self._max_models_per_worker_hint)
+      model.increment_max_models(
+          self._max_models_per_worker_hint, process_id=os.getpid())
       self._max_models_per_worker_hint = None
 
     batch_by_key = defaultdict(list)
diff --git a/sdks/python/apache_beam/ml/inference/base_test.py 
b/sdks/python/apache_beam/ml/inference/base_test.py
index 568bdf7ea12..64f5c3b1525 100644
--- a/sdks/python/apache_beam/ml/inference/base_test.py
+++ b/sdks/python/apache_beam/ml/inference/base_test.py
@@ -1978,6 +1978,41 @@ class RunInferenceBaseTest(unittest.TestCase):
         mh3.load_model, tag=tag3).acquire()
     self.assertEqual(8, model3.predict(10))
 
+  def test_model_handler_manager_increments_once_per_process(self):
+    mhs = {
+        'key1': FakeModelHandler(state=1),
+        'key2': FakeModelHandler(state=2),
+        'key3': FakeModelHandler(state=3)
+    }
+    mm = base._ModelHandlerManager(mh_map=mhs)
+    mm.increment_max_models(1, process_id=1)
+    mm.increment_max_models(1, process_id=1)
+    mm.load('key1')
+    mm.load('key2')
+    mm.load('key3')
+    self.assertEqual(['key3'], list(mm._tag_map.keys()))
+
+    mm.increment_max_models(1, process_id=2)
+    mm.load('key1')
+    self.assertEqual(['key3', 'key1'], list(mm._tag_map.keys()))
+
+  def test_keyed_model_handler_max_models_hint_with_handler_copies(self):
+    mhs = [
+        base.KeyModelMapping([k],
+                             FakeModelHandler(
+                                 state=i, multi_process_shared=True))
+        for i, k in enumerate(['a', 'b', 'c'])
+    ]
+    keyed_mh = base.KeyedModelHandler(mhs, max_models_per_worker_hint=1)
+    mm = keyed_mh.load_model()
+    # Each DoFn instance in a process deserializes its own copy of the model
+    # handler, but they all share the same _ModelHandlerManager.
+    for _ in range(5):
+      mh_copy = pickle.loads(pickle.dumps(keyed_mh))
+      mh_copy.override_metrics('test_namespace')
+      list(mh_copy.run_inference([('a', 1), ('b', 2), ('c', 3)], mm))
+    self.assertEqual(1, len(mm._tag_map))
+
   def test_run_inference_loads_different_models(self):
     mh1 = FakeModelHandler(incrementing=True, min_batch_size=3)
     with TestPipeline() as pipeline:

Reply via email to