Python Connection Pool Implementation
Databases are a critical part of many applications. Establishing a new database connection can be an expensive operation. A connection pool is a software design pattern that reuses database connections opened by the application, instead of opening and closing them for each transaction. This significantly improves performance and scalability by reducing the overhead of connection establishment. Your task is to implement a basic connection pool in Python.
Problem Description
You need to implement a ConnectionPool class in Python. This class will manage a pool of database connections. The pool should have a fixed maximum size. When a connection is requested, the pool should either provide an existing idle connection or create a new one if available. If the pool is full and all connections are in use, the request should block until a connection becomes available. When a connection is no longer needed, it should be returned to the pool.
Key Requirements:
- Initialization: The
ConnectionPoolshould be initialized with amax_connectionsparameter, specifying the maximum number of connections the pool can hold. It should also accept aconnection_factorycallable that, when called, returns a new database connection object. - Acquire Connection: A method
acquire_connection()should be provided. This method should:- Return an available connection from the pool.
- If no connections are available but the pool has not reached its
max_connectionslimit, create a new connection using theconnection_factoryand return it. - If the pool has reached its
max_connectionslimit and all connections are currently in use, the thread callingacquire_connection()should block until a connection is returned to the pool.
- Release Connection: A method
release_connection(connection)should be provided. This method takes a connection object and returns it to the pool, making it available for reuse. - Thread Safety: The
ConnectionPoolmust be thread-safe. Multiple threads will be acquiring and releasing connections concurrently. - Connection Object: The
connection_factorywill return objects that mimic database connections. For this challenge, assume these "connection" objects have a simpleclose()method, which should be called when a connection is truly discarded (e.g., when cleaning up the pool, though explicit cleanup is not the primary focus of this challenge).
Expected Behavior:
- When
acquire_connection()is called, it should always return a valid "connection" object. - When
release_connection()is called, the provided connection should be marked as available in the pool. - Concurrent calls to
acquire_connection()should be handled correctly, with threads blocking appropriately when necessary.
Edge Cases to Consider:
- What happens if
release_connection()is called with a connection that was not acquired from this pool? (For simplicity, you can assume valid connections are always passed, or raise an error.) - What happens if
release_connection()is called multiple times for the same connection? (Handle gracefully, perhaps by ignoring subsequent releases.) - Ensuring that a blocked thread eventually gets a connection when one becomes available.
Examples
Example 1: Basic Usage
import threading
import time
# Mock connection object
class MockConnection:
def __init__(self, id):
self.id = id
self.is_closed = False
print(f"Connection {self.id} created.")
def close(self):
if not self.is_closed:
self.is_closed = True
print(f"Connection {self.id} closed.")
def __repr__(self):
return f"<MockConnection id={self.id}>"
# Factory function
def create_mock_connection(conn_id_counter):
return MockConnection(next(conn_id_counter))
# --- Simulation ---
conn_id_generator = iter(range(1, 101)) # For generating unique connection IDs
pool = ConnectionPool(max_connections=3, connection_factory=lambda: create_mock_connection(conn_id_generator))
print("Acquiring connection 1...")
conn1 = pool.acquire_connection()
print(f"Acquired: {conn1}")
print("Acquiring connection 2...")
conn2 = pool.acquire_connection()
print(f"Acquired: {conn2}")
print("Acquiring connection 3...")
conn3 = pool.acquire_connection()
print(f"Acquired: {conn3}")
print("Releasing connection 1...")
pool.release_connection(conn1)
print("Connection 1 released.")
print("Acquiring connection 4...")
conn4 = pool.acquire_connection()
print(f"Acquired: {conn4}") # Should reuse conn1 if available
print("Releasing connection 2...")
pool.release_connection(conn2)
print("Connection 2 released.")
print("Releasing connection 3...")
pool.release_connection(conn3)
print("Connection 3 released.")
print("Releasing connection 4...")
pool.release_connection(conn4)
print("Connection 4 released.")
Expected Output (order may vary slightly due to thread scheduling if threads were used, but with sequential calls, it's predictable):
Connection 1 created.
Acquiring connection 1...
Acquired: <MockConnection id=1>
Connection 2 created.
Acquiring connection 2...
Acquired: <MockConnection id=2>
Connection 3 created.
Acquiring connection 3...
Acquired: <MockConnection id=3>
Releasing connection 1...
Connection 1 released.
Acquiring connection 4...
Acquired: <MockConnection id=1>
Releasing connection 2...
Connection 2 released.
Releasing connection 3...
Connection 3 released.
Releasing connection 4...
Connection 4 released.
Explanation:
The pool is initialized with a capacity of 3. The first three acquire_connection calls create and return new connections. When conn1 is released, it becomes available. The fourth acquire_connection call reuses the released conn1 instead of creating a new one.
Example 2: Thread Blocking Scenario
import threading
import time
# Mock connection object (same as above)
class MockConnection:
def __init__(self, id):
self.id = id
self.is_closed = False
print(f"Connection {self.id} created.")
def close(self):
if not self.is_closed:
self.is_closed = True
print(f"Connection {self.id} closed.")
def __repr__(self):
return f"<MockConnection id={self.id}>"
# Factory function (same as above)
def create_mock_connection(conn_id_counter):
return MockConnection(next(conn_id_counter))
# --- Simulation ---
conn_id_generator = iter(range(1, 101))
pool = ConnectionPool(max_connections=2, connection_factory=lambda: create_mock_connection(conn_id_generator))
results = {}
def worker(thread_id, delay):
print(f"Thread {thread_id} attempting to acquire connection...")
conn = pool.acquire_connection()
print(f"Thread {thread_id} acquired {conn}.")
results[thread_id] = conn
time.sleep(delay) # Simulate work
print(f"Thread {thread_id} releasing {conn}.")
pool.release_connection(conn)
# Start threads
t1 = threading.Thread(target=worker, args=(1, 2))
t2 = threading.Thread(target=worker, args=(2, 2))
t3 = threading.Thread(target=worker, args=(3, 1)) # This thread should block
t1.start()
t2.start()
time.sleep(0.5) # Give t1 and t2 time to acquire connections
t3.start()
t1.join()
t2.join()
t3.join()
print("All threads finished.")
Expected Output (order may vary slightly due to thread scheduling, but the blocking behavior is key):
Connection 1 created.
Thread 1 attempting to acquire connection...
Acquired: <MockConnection id=1>
Connection 2 created.
Thread 2 attempting to acquire connection...
Acquired: <MockConnection id=2>
Thread 3 attempting to acquire connection...
Thread 1 releasing <MockConnection id=1>.
Thread 3 acquired <MockConnection id=1>. # Reused connection
Thread 2 releasing <MockConnection id=2>.
Thread 3 releasing <MockConnection id=1>.
All threads finished.
Explanation:
The pool has max_connections=2. Threads 1 and 2 acquire the two available connections. Thread 3 attempts to acquire a connection and blocks because the pool is full. When Thread 1 releases its connection after 2 seconds, Thread 3 can then acquire that connection. Thread 2 releases its connection shortly after.
Constraints
max_connectionswill be a positive integer, not exceeding 100.- The
connection_factorywill always return a valid object with aclose()method. - Your
ConnectionPoolclass should use standard Python libraries (e.g.,threading,queue). - The implementation should be efficient, avoiding unnecessary locking or busy-waiting.
Notes
- Consider using
queue.Queuefrom Python's standard library, as it is designed for thread-safe producer-consumer scenarios and can handle blocking gracefully. - You'll need to manage the count of active connections in addition to the queue of available connections.
- The
connection_factorymight take arguments in a real-world scenario (e.g., database credentials). For this problem, a simple lambda or a function that closes over necessary state is sufficient. - Think about how to signal threads that are waiting for a connection when one becomes available.