Skip to content

Commit 6080069

Browse files
feature: generic object pool (#14702)
* add: generic object pool & tests Introduced a reusable object pool that can be applied across the codebase. Note: memory growth is managed via eviction settings—using a hard cap could reduce performance, so eviction is the preferred safeguard. * fix: simpler tests
1 parent 114d077 commit 6080069

File tree

2 files changed

+242
-0
lines changed

2 files changed

+242
-0
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""
2+
Generic object pooling utilities for LiteLLM.
3+
4+
This module provides a flexible object pooling system that can be used
5+
to pool any type of object, reducing memory allocation overhead and
6+
improving performance for frequently created/destroyed objects.
7+
8+
Memory Management Strategy:
9+
- Balanced eviction-based memory control to optimize reuse ratio
10+
- Moderate eviction frequency (300s) to maintain high object reuse
11+
- Conservative eviction weight (0.3) to avoid destroying useful objects
12+
- Lower pre-warm count (5) to reduce initial memory footprint
13+
- Always keeps at least one object available for high availability
14+
- Unlimited pools when maxsize is not specified (eviction controls actual usage)
15+
"""
16+
17+
from typing import Type, TypeVar, Optional, Callable
18+
from pond import Pond, PooledObjectFactory, PooledObject
19+
20+
T = TypeVar('T')
21+
22+
class GenericPooledObjectFactory(PooledObjectFactory):
23+
"""Generic factory class for creating pooled objects of any type."""
24+
25+
def __init__(
26+
self,
27+
object_class: Type[T],
28+
pooled_maxsize: Optional[int] = None, # None = unlimited pool with eviction-based memory control
29+
least_one: bool = True, # Always keep at least one for high concurrency
30+
initializer: Optional[Callable[[T], None]] = None
31+
):
32+
# Only pass maxsize to Pond if user specified it - otherwise let Pond handle unlimited pools
33+
if pooled_maxsize is not None:
34+
super().__init__(pooled_maxsize=pooled_maxsize, least_one=least_one)
35+
else:
36+
super().__init__(least_one=least_one)
37+
self.object_class = object_class
38+
self.initializer = initializer
39+
self._user_maxsize = pooled_maxsize # Store original user preference
40+
41+
def createInstance(self) -> PooledObject:
42+
"""Create a new instance wrapped in a PooledObject."""
43+
# Create a properly initialized instance
44+
obj = self.object_class()
45+
return PooledObject(obj)
46+
47+
def destroy(self, pooled_object: PooledObject):
48+
"""Destroy the pooled object."""
49+
if hasattr(pooled_object.keeped_object, '__dict__'):
50+
pooled_object.keeped_object.__dict__.clear()
51+
del pooled_object
52+
53+
def reset(self, pooled_object: PooledObject) -> PooledObject:
54+
"""Reset the pooled object to a clean state."""
55+
obj = pooled_object.keeped_object
56+
# Reset the object by calling its reset method if it exists
57+
if hasattr(obj, 'reset') and callable(getattr(obj, 'reset')):
58+
obj.reset()
59+
else:
60+
# Fallback: clear all attributes to reset the object
61+
if hasattr(obj, '__dict__'):
62+
obj.__dict__.clear()
63+
return pooled_object
64+
65+
def validate(self, pooled_object: PooledObject) -> bool:
66+
"""Validate if the pooled object is still usable."""
67+
return pooled_object.keeped_object is not None
68+
69+
# Global pond instances
70+
_pools: dict[str, Pond] = {}
71+
72+
def get_object_pool(
73+
pool_name: str,
74+
object_class: Type[T],
75+
pooled_maxsize: Optional[int] = None, # None = unlimited pool with eviction-based memory control
76+
least_one: bool = True, # Always keep at least one
77+
borrowed_timeout: int = 10, # Longer timeout for high concurrency
78+
time_between_eviction_runs: int = 300, # Less frequent eviction to maintain high reuse ratio
79+
eviction_weight: float = 0.3, # Less aggressive eviction for better reuse
80+
prewarm_count: int = 5 # Lower pre-warm count to reduce initial memory usage
81+
) -> Pond:
82+
"""Get or create a global object pool instance with balanced eviction-based memory control.
83+
84+
Memory is controlled through moderate eviction to balance reuse ratio and memory usage:
85+
- Moderate eviction frequency (300s) to maintain high object reuse ratio
86+
- Conservative eviction weight (0.3) to avoid destroying useful objects
87+
- Lower pre-warm count (5) to reduce initial memory footprint
88+
89+
Args:
90+
pool_name: Unique name for the pool
91+
object_class: The class type to pool
92+
pooled_maxsize: Maximum number of objects in the pool (None = truly unlimited)
93+
least_one: Whether to keep at least one object in the pool (default: True)
94+
borrowed_timeout: Timeout for borrowing objects (seconds, default: 10)
95+
time_between_eviction_runs: Time between eviction runs (seconds, default: 300)
96+
eviction_weight: Weight for eviction algorithm (default: 0.3, conservative)
97+
prewarm_count: Number of objects to pre-warm the pool with (default: 5)
98+
99+
Returns:
100+
Pond instance for the specified object type
101+
"""
102+
103+
if pool_name in _pools:
104+
return _pools[pool_name]
105+
106+
# Create new pond
107+
pond = Pond(
108+
borrowed_timeout=borrowed_timeout,
109+
time_between_eviction_runs=time_between_eviction_runs,
110+
thread_daemon=True,
111+
eviction_weight=eviction_weight
112+
)
113+
114+
# Register the factory with user's maxsize preference
115+
factory = GenericPooledObjectFactory(
116+
object_class=object_class,
117+
pooled_maxsize=pooled_maxsize,
118+
least_one=least_one
119+
)
120+
pond.register(factory, name=f"{pool_name}Factory")
121+
122+
# Pre-warm the pool
123+
_prewarm_pool(pond, pool_name, prewarm_count)
124+
125+
_pools[pool_name] = pond
126+
return pond
127+
128+
def _prewarm_pool(pond: Pond, pool_name: str, prewarm_count: int = 20) -> None:
129+
"""Pre-warm the pool with initial objects for high concurrency."""
130+
for _ in range(prewarm_count):
131+
try:
132+
pooled_obj = pond.borrow(name=f"{pool_name}Factory")
133+
pond.recycle(pooled_obj, name=f"{pool_name}Factory")
134+
except Exception:
135+
# If pre-warming fails, just continue
136+
break
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
Simplified tests for object pooling utilities in litellm.
3+
"""
4+
5+
import pytest
6+
7+
from litellm.litellm_core_utils.object_pooling import (
8+
get_object_pool,
9+
_pools
10+
)
11+
12+
13+
class SimpleObject:
14+
"""Simple test object with internal reset tracking."""
15+
def __init__(self):
16+
self.data = {}
17+
self.reset_count = 0
18+
self.creation_id = id(self)
19+
20+
def reset(self):
21+
"""Reset method that tracks how many times it's called."""
22+
self.data.clear()
23+
self.reset_count += 1
24+
25+
def set_data(self, key, value):
26+
"""Set data to verify reset works."""
27+
self.data[key] = value
28+
29+
30+
class SimpleObjectNoReset:
31+
"""Test object without reset method."""
32+
def __init__(self):
33+
self.data = {}
34+
self.creation_id = id(self)
35+
36+
37+
class TestObjectPooling:
38+
"""Simplified test suite for object pooling."""
39+
40+
def setup_method(self):
41+
"""Clear pools before each test."""
42+
_pools.clear()
43+
44+
def test_reset_method_works(self):
45+
"""Test that reset method is called when recycling objects."""
46+
pool_name = "reset_test"
47+
pool = get_object_pool(pool_name, SimpleObject, pooled_maxsize=1, prewarm_count=0)
48+
49+
# Get an object and modify it
50+
obj = pool.borrow(name=f"{pool_name}Factory")
51+
obj.keeped_object.set_data("test", "value")
52+
initial_reset_count = obj.keeped_object.reset_count
53+
54+
# Return to pool (should trigger reset)
55+
pool.recycle(obj, name=f"{pool_name}Factory")
56+
57+
# Get the same object back
58+
obj2 = pool.borrow(name=f"{pool_name}Factory")
59+
60+
# Verify reset was called
61+
assert obj2.keeped_object.reset_count == initial_reset_count + 1
62+
assert obj2.keeped_object.data == {} # Data should be cleared
63+
assert obj.keeped_object.creation_id == obj2.keeped_object.creation_id # Same object
64+
65+
def test_fallback_reset_works(self):
66+
"""Test fallback reset when no reset method exists."""
67+
pool_name = "fallback_test"
68+
pool = get_object_pool(pool_name, SimpleObjectNoReset, pooled_maxsize=1, prewarm_count=0)
69+
70+
# Get an object and modify it
71+
obj = pool.borrow(name=f"{pool_name}Factory")
72+
obj.keeped_object.data["test"] = "value"
73+
74+
# Return to pool (should trigger fallback reset)
75+
pool.recycle(obj, name=f"{pool_name}Factory")
76+
77+
# Get the same object back
78+
obj2 = pool.borrow(name=f"{pool_name}Factory")
79+
80+
# Verify fallback reset worked - all attributes should be cleared by __dict__.clear()
81+
assert obj2.keeped_object.__dict__ == {}, "All attributes should be cleared by fallback reset"
82+
assert obj is obj2, "Should be the same pooled object instance"
83+
84+
def test_pool_reuses_objects(self):
85+
"""Test that pool actually reuses objects instead of creating new ones."""
86+
pool_name = "reuse_test"
87+
pool = get_object_pool(pool_name, SimpleObject, pooled_maxsize=1, prewarm_count=0)
88+
89+
# Get first object
90+
obj1 = pool.borrow(name=f"{pool_name}Factory")
91+
creation_id1 = obj1.keeped_object.creation_id
92+
93+
# Return it
94+
pool.recycle(obj1, name=f"{pool_name}Factory")
95+
96+
# Get second object
97+
obj2 = pool.borrow(name=f"{pool_name}Factory")
98+
creation_id2 = obj2.keeped_object.creation_id
99+
100+
# Should be the same object (reused)
101+
assert creation_id1 == creation_id2, "Pool should reuse objects"
102+
assert obj1.keeped_object is obj2.keeped_object, "Should be same object instance"
103+
104+
105+
if __name__ == "__main__":
106+
pytest.main([__file__])

0 commit comments

Comments
 (0)