Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/tools/parallel/pools.py

720 lines
21 KiB
Python

"""Pools Module.
Resource pooling utilities using standard library only.
Key Features:
- Thread pool for concurrent tasks
- Connection pool for database connections
- Object pool for resource reuse
- Pool lifecycle management
- Thread-safe operations
- Free-threaded Python compatible
- Standard library only (no external dependencies)
Dependencies:
- threading (standard library)
- queue (standard library)
- contextlib (standard library)
- sys (for GIL detection)
"""
import threading
import queue
from typing import Any, Callable, Optional, Generic, TypeVar, List
from contextlib import contextmanager
import time
import sys
class PoolError(Exception):
"""Pool operation error"""
T = TypeVar('T')
def _is_free_threaded() -> bool:
"""Check if running in free-threaded mode (Python 3.13+ with --disable-gil).
Returns:
True if running in free-threaded mode, False otherwise
"""
return hasattr(sys, 'flags') and getattr(sys.flags, 'gil', 1) == 0
class ObjectPool(Generic[T]):
"""Generic object pool for resource reuse.
Maintains a pool of reusable objects with automatic lifecycle management.
Thread-safe for concurrent access and free-threading compatible.
"""
def __init__(
self,
factory: Callable[[], T],
max_size: int = 10,
timeout: float = 5.0,
reset_func: Optional[Callable[[T], None]] = None,
destroy_func: Optional[Callable[[T], None]] = None,
use_rlock: bool = True # Use RLock instead of Lock for better free-threaded compatibility
):
"""Initialize object pool.
Args:
factory: Function to create new objects
max_size: Maximum pool size
timeout: Timeout for acquiring objects
reset_func: Function to reset object before reuse
destroy_func: Function to destroy object when removing from pool
use_rlock: Use RLock instead of Lock for recursive locking in free-threaded mode
"""
self.factory = factory
self.max_size = max_size
self.timeout = timeout
self.reset_func = reset_func
self.destroy_func = destroy_func
self._pool = queue.Queue(maxsize=max_size)
self._active_count = 0
self._lock = threading.RLock() if use_rlock else threading.Lock()
self._closed = False
self._is_free_threaded = _is_free_threaded()
@property
def is_free_threaded(self) -> bool:
"""Check if running in free-threaded mode.
Returns:
True if running in free-threaded mode, False otherwise
"""
return self._is_free_threaded
def get_optimal_pool_size(self, estimated_concurrent_usage: Optional[int] = None) -> int:
"""Get optimal pool size based on Python version and usage patterns.
Args:
estimated_concurrent_usage: Expected number of concurrent users
Returns:
Optimal pool size
"""
if estimated_concurrent_usage is not None:
if self._is_free_threaded:
# In free-threaded mode, can support more concurrent usage
return max(self.max_size, estimated_concurrent_usage * 2)
else:
# With GIL, be more conservative
return max(self.max_size, estimated_concurrent_usage)
else:
# Use current max_size
return self.max_size
def acquire(self, timeout: Optional[float] = None) -> T:
"""Acquire an object from the pool.
Args:
timeout: Timeout in seconds (None = use pool default)
Returns:
Object from pool
Raises:
PoolError: If pool is closed or timeout
"""
if self._closed:
raise PoolError("Pool is closed")
if timeout is None:
timeout = self.timeout
try:
# Try to get existing object from pool
obj = self._pool.get(timeout=timeout)
return obj
except queue.Empty:
# No objects available, try to create new one
with self._lock:
if self._active_count < self.max_size:
# Create new object
self._active_count += 1
try:
obj = self.factory()
return obj
except Exception as e:
self._active_count -= 1
raise PoolError(f"Failed to create object: {e}") from e
# Pool is at capacity, wait for object
raise PoolError(f"Pool exhausted, timeout after {timeout}s")
def release(self, obj: T) -> None:
"""Release an object back to the pool.
Args:
obj: Object to release
Raises:
PoolError: If pool is closed
"""
if self._closed:
# Destroy object if pool is closed
if self.destroy_func:
try:
self.destroy_func(obj)
except Exception:
pass
return
try:
# Reset object if reset function provided
if self.reset_func:
self.reset_func(obj)
# Return to pool
self._pool.put_nowait(obj)
except queue.Full:
# Pool is full, destroy the object
with self._lock:
self._active_count -= 1
if self.destroy_func:
try:
self.destroy_func(obj)
except Exception:
pass
@contextmanager
def get_object(self, timeout: Optional[float] = None):
"""Context manager for automatic acquire/release.
Args:
timeout: Timeout in seconds
Yields:
Object from pool
Example:
with pool.get_object() as obj:
# Use obj
obj.do_something()
# Automatically released
"""
obj = self.acquire(timeout)
try:
yield obj
finally:
self.release(obj)
def close(self) -> None:
"""Close the pool and destroy all objects."""
with self._lock:
if self._closed:
return
self._closed = True
# Destroy all pooled objects
if self.destroy_func:
while not self._pool.empty():
try:
obj = self._pool.get_nowait()
self.destroy_func(obj)
except (queue.Empty, Exception):
break
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close()
return False
@property
def size(self) -> int:
"""Get current pool size.
Returns:
Number of objects in pool
"""
return self._pool.qsize()
@property
def active(self) -> int:
"""Get number of active objects.
Returns:
Number of objects currently in use
"""
with self._lock:
return self._active_count
class ConnectionPool:
"""Database connection pool.
Specialized pool for managing database connections.
Free-threading compatible with appropriate locking.
"""
def __init__(
self,
connect_func: Callable[[], Any],
max_connections: int = 10,
timeout: float = 5.0,
test_on_borrow: bool = True
):
"""Initialize connection pool.
Args:
connect_func: Function to create new connection
max_connections: Maximum number of connections
timeout: Timeout for acquiring connection
test_on_borrow: Test connection health before returning
"""
self._is_free_threaded = _is_free_threaded()
self.connect_func = connect_func
self.test_on_borrow = test_on_borrow
# Create object pool with connection-specific handlers
self._pool = ObjectPool(
factory=connect_func,
max_size=max_connections,
timeout=timeout,
reset_func=None, # Connections don't need reset
destroy_func=self._close_connection
)
@property
def is_free_threaded(self) -> bool:
"""Check if running in free-threaded mode.
Returns:
True if running in free-threaded mode, False otherwise
"""
return self._is_free_threaded
def _close_connection(self, conn: Any) -> None:
"""Close a database connection.
Args:
conn: Connection to close
"""
try:
if hasattr(conn, 'close'):
conn.close()
except Exception:
pass
def _test_connection(self, conn: Any) -> bool:
"""Test if connection is still valid.
Args:
conn: Connection to test
Returns:
True if connection is valid
"""
try:
# Try to execute a simple query
if hasattr(conn, 'execute'):
conn.execute('SELECT 1')
return True
return True
except Exception:
return False
def acquire(self, timeout: Optional[float] = None) -> Any:
"""Acquire a connection from pool.
Args:
timeout: Timeout in seconds
Returns:
Database connection
Raises:
PoolError: If unable to acquire connection
"""
conn = self._pool.acquire(timeout)
# Test connection if required
if self.test_on_borrow:
if not self._test_connection(conn):
# Connection is bad, destroy it and try again
self._close_connection(conn)
return self.acquire(timeout)
return conn
def release(self, conn: Any) -> None:
"""Release connection back to pool.
Args:
conn: Connection to release
"""
self._pool.release(conn)
@contextmanager
def get_connection(self, timeout: Optional[float] = None):
"""Context manager for connection.
Args:
timeout: Timeout in seconds
Yields:
Database connection
Example:
with pool.get_connection() as conn:
cursor = conn.execute("SELECT * FROM users")
# Connection automatically released
"""
with self._pool.get_object(timeout) as conn:
yield conn
def close(self) -> None:
"""Close all connections in pool."""
self._pool.close()
@property
def size(self) -> int:
"""Get pool size."""
return self._pool.size
@property
def active(self) -> int:
"""Get active connection count."""
return self._pool.active
class WorkerPool:
"""Worker pool for task execution.
Maintains a pool of worker threads for executing tasks.
Free-threading compatible with appropriate locking.
"""
def __init__(self, workers: int = 4, queue_size: int = 100):
"""Initialize worker pool.
Args:
workers: Number of worker threads
queue_size: Maximum queue size (0 = unlimited)
"""
self._is_free_threaded = _is_free_threaded()
self.workers = workers
self.queue_size = queue_size
if queue_size > 0:
self._queue = queue.Queue(maxsize=queue_size)
else:
self._queue = queue.Queue()
self._threads: List[threading.Thread] = []
self._running = False
self._lock = threading.RLock() if self._is_free_threaded else threading.Lock()
@property
def is_free_threaded(self) -> bool:
"""Check if running in free-threaded mode.
Returns:
True if running in free-threaded mode, False otherwise
"""
return self._is_free_threaded
def get_optimal_workers(self, base_workers: Optional[int] = None) -> int:
"""Get optimal number of workers based on Python version.
Args:
base_workers: Base number of workers (None = use self.workers)
Returns:
Optimal number of workers
"""
if base_workers is None:
base_workers = self.workers
if self._is_free_threaded:
# In free-threaded mode, can use more workers efficiently
return base_workers * 2
else:
# With GIL, be more conservative
return base_workers
def start(self) -> None:
"""Start worker threads."""
with self._lock:
if self._running:
return
self._running = True
# Get optimal number of workers based on Python version
optimal_workers = self.get_optimal_workers()
# Create and start worker threads
for i in range(optimal_workers):
thread = threading.Thread(
target=self._worker,
name=f"Worker-{i}",
daemon=True
)
thread.start()
self._threads.append(thread)
def _worker(self) -> None:
"""Worker thread main loop."""
while self._running:
try:
# Get task from queue (blocking to avoid polling)
task = self._queue.get()
if task is None: # Poison pill
break
# Execute task
func, args, kwargs = task
try:
func(*args, **kwargs)
except Exception:
# Ignore task errors (could log here)
pass
finally:
self._queue.task_done()
except queue.Empty:
# This shouldn't happen with blocking get, but handle gracefully
continue
def submit(
self,
func: Callable,
*args,
timeout: Optional[float] = None,
**kwargs
) -> None:
"""Submit a task to the pool.
Args:
func: Function to execute
*args: Positional arguments for func
timeout: Timeout for queue insertion
**kwargs: Keyword arguments for func
Raises:
PoolError: If pool is not running or queue is full
"""
if not self._running:
raise PoolError("Worker pool is not running")
try:
task = (func, args, kwargs)
self._queue.put(task, timeout=timeout)
except queue.Full:
raise PoolError("Worker queue is full")
def shutdown(self, wait: bool = True, timeout: Optional[float] = None) -> None:
"""Shutdown worker pool.
Args:
wait: Wait for tasks to complete
timeout: Maximum time to wait in seconds
"""
with self._lock:
if not self._running:
return
self._running = False
if wait:
# Wait for all tasks to complete using queue.join()
start_time = time.monotonic()
try:
self._queue.join()
except Exception:
# If join() fails, fall back to polling
while not self._queue.empty():
if timeout and (time.monotonic() - start_time) > timeout:
break
time.sleep(0.1)
# Send poison pills to workers - ensure we send exactly one per worker
for _ in range(len(self._threads)):
try:
# Use blocking put with timeout to avoid deadlock
self._queue.put(None, block=True, timeout=1.0)
except queue.Full:
# Queue is full, but this shouldn't happen if we used queue.join()
pass
except Exception:
# If put fails, continue anyway
pass
# Wait for threads to finish
for thread in self._threads:
if timeout:
remaining = timeout - (time.monotonic() - start_time)
thread.join(timeout=max(0, remaining))
else:
thread.join()
self._threads.clear()
@property
def pending(self) -> int:
"""Get number of pending tasks.
Returns:
Number of tasks in queue
"""
return self._queue.qsize()
def __enter__(self):
"""Context manager entry."""
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.shutdown(wait=True)
return False
class Pools:
"""Factory class for creating various types of pools."""
@staticmethod
def is_free_threaded() -> bool:
"""Check if running in free-threaded mode.
Returns:
True if running in free-threaded mode, False otherwise
"""
return _is_free_threaded()
@staticmethod
def create_pool(
factory: Callable[[], T],
max_size: int = 10,
**kwargs
) -> ObjectPool[T]:
"""Create a generic object pool.
Args:
factory: Function to create objects
max_size: Maximum pool size
**kwargs: Additional pool arguments
Returns:
ObjectPool instance
"""
return ObjectPool(factory=factory, max_size=max_size, **kwargs)
@staticmethod
def create_pool_optimized(
factory: Callable[[], T],
max_size: int = 10,
**kwargs
) -> ObjectPool[T]:
"""Create a generic object pool optimized for current Python version.
Args:
factory: Function to create objects
max_size: Base maximum pool size (will be adjusted for free-threading)
**kwargs: Additional pool arguments
Returns:
ObjectPool instance optimized for current environment
"""
is_free_threaded = _is_free_threaded()
# Adjust max_size based on threading mode if not explicitly set
if 'max_size' not in kwargs and is_free_threaded:
max_size = max_size * 2 # More capacity in free-threaded mode
return ObjectPool(factory=factory, max_size=max_size, use_rlock=True, **kwargs)
@staticmethod
def create_connection_pool(
connect_func: Callable[[], Any],
max_connections: int = 10,
**kwargs
) -> ConnectionPool:
"""Create a connection pool.
Args:
connect_func: Function to create connections
max_connections: Maximum number of connections
**kwargs: Additional pool arguments
Returns:
ConnectionPool instance
"""
return ConnectionPool(
connect_func=connect_func,
max_connections=max_connections,
**kwargs
)
@staticmethod
def create_connection_pool_optimized(
connect_func: Callable[[], Any],
max_connections: int = 10,
**kwargs
) -> ConnectionPool:
"""Create a connection pool optimized for current Python version.
Args:
connect_func: Function to create connections
max_connections: Base maximum connections (will be adjusted for free-threading)
**kwargs: Additional pool arguments
Returns:
ConnectionPool instance optimized for current environment
"""
if _is_free_threaded():
# In free-threaded mode, can handle more concurrent connections
max_connections = max_connections * 2
return ConnectionPool(
connect_func=connect_func,
max_connections=max_connections,
**kwargs
)
@staticmethod
def create_worker_pool(
workers: int = 4,
queue_size: int = 100
) -> WorkerPool:
"""Create a worker pool.
Args:
workers: Number of worker threads
queue_size: Maximum queue size
Returns:
WorkerPool instance
"""
return WorkerPool(workers=workers, queue_size=queue_size)
@staticmethod
def create_worker_pool_optimized(
workers: int = 4,
queue_size: int = 100
) -> WorkerPool:
"""Create a worker pool optimized for current Python version.
Args:
workers: Base number of worker threads (will be adjusted for free-threading)
queue_size: Maximum queue size
Returns:
WorkerPool instance optimized for current environment
"""
if _is_free_threaded():
# In free-threaded mode, can use more workers efficiently
workers = workers * 2
return WorkerPool(workers=workers, queue_size=queue_size)