Pooling#
Opening a connection costs several round trips and a new server process, so a service that answers
many requests should keep connections open and lend them out. ConnectionPool does that. It wraps
psycopg’s pool and adds what a graph
service needs on top: the graph selected on every connection, one budget for waiting and running,
a way to retire every connection at once, and the safe handling of interrupted statements.
Class |
|
|---|---|
|
keeps between |
|
keeps none: opens a connection per borrow and closes it after |
|
the same, for awaiting code |
Creating and opening a pool#
import agensgraph
pool = agensgraph.ConnectionPool(
"host=localhost dbname=docs user=agens",
graph="moviekb",
min_size=2,
max_size=8,
kwargs={"autocommit": True},
)
pool.open(wait=True)
Creating a pool connects nothing. open() starts it. It first makes one connection of its own and
closes it again, so a server that cannot be reached, or that the driver cannot read, fails there,
at startup, with the real reason, rather than being retried in the background for minutes while
requests queue. wait=True then waits until min_size connections exist.
graph= is selected on each connection when it is opened, and committed, so a rollback during a
borrow cannot undo it. kwargs are passed to connect() for every connection.
Prefer autocommit for a pool, as above, and open a conn.transaction() where a borrow needs
one. A pool that is not in autocommit commits each borrow when its block ends and rolls back on an
exception, which is also correct, but then every borrow is already a transaction, and a
transaction() block inside one is only a savepoint.
Borrowing a connection#
>>> with pool.connection() as conn:
... conn.execute_query("MATCH (p:person) RETURN count(p)").records
[(28,)]
The connection goes back to the pool when the block ends. For a single statement,
pool.execute_query() borrows, runs and returns in one call:
>>> pool.execute_query("MATCH (m:movie) WHERE m.year > %s RETURN count(m)", (2010,)).records
[(5,)]
Do not keep the handle past the block. The connection may already be lent to someone else, and work done on it then would land in their transaction. While it sits idle in the pool, using it is refused:
>>> with pool.connection() as conn:
... kept = conn
>>> kept.execute_query("RETURN 1")
Traceback (most recent call last):
...
agensgraph.errors.ReleasedConnection: this connection has gone back to the pool, and may already have been lent to somebody else. Take one with `with pool.connection() as conn:` for each piece of work, and do not keep the handle past the block
Once the pool has lent it to someone else, the driver can no longer tell the two holders apart, so the refusal is a safeguard and not a guarantee.
A budget for the whole borrow#
Pass a Deadline to bound the wait for a connection and the statements run on it together:
>>> from agensgraph import Deadline
>>> with pool.connection(deadline=Deadline(5.0)) as conn:
... conn.execute("SHOW statement_timeout").fetchone()
('4499ms',)
Time spent waiting for a connection is time the statements no longer have. The rest of the budget
reaches the server as statement_timeout, set once when the connection is handed out, a little
inside what is left so the server gives up before you do, and put back before the next borrower
gets the connection. Timeouts and cancellation explains the details.
timeout= on connection() is a budget too: it bounds the wait and sets the statement limit the
same way.
Hooks#
Three functions run at fixed points in a connection’s life:
Argument |
Runs |
For |
|---|---|---|
|
once, when a connection is opened, after the graph is selected |
anything a connection keeps: |
|
each time a connection is lent |
per-borrow settings |
|
each time a connection comes back |
cleaning up after a borrower |
vector_pool = agensgraph.ConnectionPool(
"host=localhost dbname=docs user=agens",
graph="moviekb",
kwargs={"autocommit": True},
configure=lambda conn: conn.register_vectors(),
)
In the blocking pools these are plain functions; in the awaiting pools they are coroutine
functions and are awaited. If reset= raises, the connection is closed instead of reused.
The graph is selected once per connection, not on each borrow. A borrower that switches graphs with
conn.graph() leaves the connection on that graph for the next borrower; if borrowers switch, select
the graph again in setup=.
After the server restarts#
A server restart breaks every connection a pool holds, and each would fail in turn, once for each
caller who happened to borrow it. invalidate() retires all of them in one call:
>>> pool.generation
0
>>> pool.invalidate()
1
It closes nothing and waits for nothing. Each connection belongs to the generation it was opened in;
a borrow never receives one from an older generation, and a connection in use when you called
invalidate() stays usable until its holder is done, then is closed instead of returned. The pool
replaces them as it needs to.
drain() does a different job: it closes every connection and opens the same number again,
without changing the generation. Use it when the connections are not wrong but you want new ones
built differently, such as after changing what configure= does, or after rotating the password
behind a connection string given as a function. (A conninfo given as a function is called for
every new connection.)
A statement interrupted mid-flight#
A connection whose statement was interrupted while the driver was sending it and waiting for the
answer, by a cancelled task, a timeout around the call, or KeyboardInterrupt, is closed when it
comes back rather than lent again. Its state can no longer be described: the statement may still be
running, and an unread result may be waiting on the socket. (A connection that comes back in the
middle of any other operation, such as a stream or a copy, is not in step either, and psycopg’s pool
closes it in turn.)
Replacing it costs one connection on an event that is rare. A statement that merely failed, with
an error from the server or a value the driver refused, keeps its connection, which is idle and in
step.
Cancellation describes how to stop a statement so that its connection can be kept.
Watching a pool#
>>> stats = pool.get_stats()
>>> sorted(stats)
['connections_errors', 'connections_interrupted', 'connections_lost', 'connections_ms', 'connections_num', 'connections_retired', 'generation', 'pool_available', 'pool_max', 'pool_min', 'pool_size', 'requests_errors', 'requests_num', 'requests_queued', 'requests_wait_ms', 'requests_waiting', 'returns_bad', 'usage_ms']
psycopg’s own counters, plus generation, connections_retired (closed for being of an old
generation) and connections_interrupted. Every counter is always present, at zero when nothing has
moved it, so an exporter can read them on a timer without checking. agensgraph.pool.COUNTERS names
the ten that accumulate, and pop_stats() returns the same with those reset, for reporting an
interval.
A pool that is too small looks like a slow database. Read requests_wait_ms, the time borrowers
spent waiting, against requests_queued, how many had to wait. Measured with six slow borrowers
against a pool of two: almost nine seconds of waiting across five queued requests, while a trivial
query took nearly three seconds just to get a connection.
Without a pool: NullConnectionPool#
For a process that handles one request and exits, or one that may be frozen between requests, keeping
connections is waste. NullConnectionPool opens one per borrow and closes it after. Everything else is
the same, so moving between the two is a change of class:
per_request = agensgraph.NullConnectionPool(
"host=localhost dbname=docs user=agens", graph="moviekb"
)
A borrow and one statement then cost about eight times what they cost from a pool that keeps its connections, which is what connecting costs. Do not choose it for a process that lives long enough to reuse a connection.
Awaiting code#
pool = agensgraph.AsyncConnectionPool("host=localhost dbname=docs user=agens", graph="moviekb",
kwargs={"autocommit": True})
await pool.open(wait=True)
async with pool.connection() as conn:
result = await conn.execute_query("MATCH (p:person) RETURN count(p)")
await pool.close()
open(), wait(), close(), check(), drain(), resize() and execute_query() are awaited;
invalidate(), get_stats() and pop_stats() are not.
Sizing#
min_size defaults to 4 and max_size to 16. A min_size above 16 needs a max_size too, or the pool
is refused. The other psycopg settings pass straight through: timeout (the longest a borrow waits by
default, 30 seconds), max_waiting, max_lifetime, max_idle, reconnect_timeout, num_workers and
name. check_connections=True, the default, checks each connection as it is lent, so a connection the
network has broken is replaced rather than handed to a caller; it costs an empty statement per borrow.
Closing#
Close the pool when the service stops:
pool.close()
A pool is also a context manager, with agensgraph.ConnectionPool(...) as pool:, which opens it
without waiting, so call pool.wait() inside it to fail at startup, and closes it at the end.