Chapter 13 · Application Drivers and Bolt: Sessions, Routing, Parameters, Result Streaming, and Connection Pools
Connection Pool Sizing, Timeouts, Lifetime, Health Checks, and Avoiding One-Session-per-Request Anti-Patterns
Size and protect the connection pool from measured concurrency and network behavior, keeping sessions short-lived and one Driver long-lived.
Learning outcomes
AtlasMart receives a burst of 200 concurrent API calls and someone proposes “set the pool to 1000.” That number is meaningless without database capacity, per-host semantics, request duration, transaction concurrency and connection budgets. This lesson treats the pool as a queueing boundary with explicit timeouts and failure signals.
Explain per-host pool limits and why one long-lived Driver owns multiple reusable connections.
Separate connection establishment timeout, pool-acquisition timeout, transaction timeout and retry budget.
Use connection lifetime/liveness checks to coexist with firewalls/load balancers without unnecessary churn.
Build a bounded concurrent load test that records success, acquisition failures and latency percentiles.
Detect the truly harmful anti-pattern: a new Driver per request, not a short-lived Session per request.
The mandatory lab continues Neo4j Community
2026.07.1, database neo4j, explicit
CYPHER 25 where query-language version matters,
container atlasmart-neo4j, loopback Bolt
127.0.0.1:7687, authentication
neo4j/atlasmart-course-2026, and no
TLS on the loopback-only disposable instance. The current
official Python driver is neo4j 6.3.0 (Python
3.10–3.14), whose API supports Bolt 6.0–6.1, Bolt 5.0–5.8 and
Bolt 4.4. Neo4j 5.26.30 remains the LTS
comparison line.
This generation environment does not connect to the AtlasMart Neo4j container, so no handshake, routing-table, pool, TLS, bookmark, latency or retry output is fabricated. Each exercise gives commands and invariants to capture on your machine. True multi-member read/write routing requires an Enterprise cluster or Aura deployment; the mandatory Community path proves the driver/session/pool/stream semantics locally and labels cluster-only observations separately.
1. Correct the anti-pattern wording first
The dangerous pattern is one Driver/pool per request or sharing one Session concurrently. A session is lightweight and generally should be scoped to one request/unit of work. The application-lifetime Driver owns the reusable pool.
| Configuration | Python driver 6.3 default | What it limits |
|---|---|---|
max_connection_pool_size |
100 per host | simultaneous idle + in-use pooled connections for that host |
connection_acquisition_timeout |
60 s | wait to obtain/create a usable pooled connection |
connection_timeout |
30 s | TCP connect establishment only; not the entire query |
max_connection_lifetime |
3600 s | age after which a connection is retired from the pool |
liveness_check_timeout |
None | idle age threshold before pre-use liveness testing; disabled by default |
max_transaction_retry_time |
driver-defined documented retry budget; configure deliberately | managed-transaction retry horizon, not request deadline |
2. Pool size follows concurrency math, not folklore
If an instance receives 40 concurrent database-active requests and each request holds one connection while streaming for 200 ms, a pool of 10 creates queueing by design; a pool of 1000 may only move overload to Neo4j. Start from measured concurrent database occupancy, server connection capacity, number of application instances and cluster hosts.
driver = GraphDatabase.driver( URI, auth=AUTH, max_connection_pool_size=20, connection_acquisition_timeout=2.0, connection_timeout=5.0, max_connection_lifetime=1800.0, liveness_check_timeout=60.0, max_transaction_retry_time=10.0,)
The numbers above are a lab starting point, not a production recommendation. In a routing driver the pool limit is per host, so total possible connections can scale with discovered cluster members and application identities.
3. Timeouts answer different failure questions
| Timeout/budget | Question answered | Do not confuse with |
|---|---|---|
| connection timeout | Can TCP connect to this address promptly? | authentication, routing, query runtime |
| acquisition timeout | Can this request obtain a pool connection before its queue budget expires? | server query timeout |
| transaction timeout | Can this database unit of work finish before the server terminates it? | network connect timeout |
| managed retry budget | How long may the driver retry retryable transaction failures? | overall HTTP/SLO deadline |
| application request deadline | How long may the end-to-end API consume? | any single driver/server timeout |
A good service has an explicit outer SLO/deadline and chooses inner budgets that leave time for useful error handling. A client-side timeout can leave commit outcome ambiguous; do not infer “rolled back” merely because the caller stopped waiting.
4. Health checks: useful but not free
verify_connectivity() is a startup/readiness
diagnostic, not a per-request prerequisite.
liveness_check_timeout optionally checks
connections that have sat idle longer than a threshold; setting
it to zero causes a check every acquisition and adds round
trips. Usually leave it alone until network equipment is proven
to drop idle connections.
def readiness(store: AtlasMartNeo4j) -> tuple[bool, str]: try: store.driver.verify_connectivity() return True, "neo4j reachable" except Exception as exc: # map to structured internal diagnostics; do not leak credentials return False, type(exc).__name__
5. Load-test the pool without hiding failures
from concurrent.futures import ThreadPoolExecutor, as_completedfrom statistics import medianfrom time import perf_counterQUERY = "CYPHER 25 MATCH (p:Product {productId:$id}) RETURN p.name AS name"def one(i): t0 = perf_counter() try: records, summary, _ = store.driver.execute_query( QUERY, id="P-1001", database_="neo4j", routing_=RoutingControl.READ) return {"ok": True, "ms": (perf_counter()-t0)*1000, "available_ms": summary.result_available_after} except Exception as exc: return {"ok": False, "ms": (perf_counter()-t0)*1000, "error": type(exc).__name__}with ThreadPoolExecutor(max_workers=32) as pool: results = [f.result() for f in as_completed([pool.submit(one, i) for i in range(200)])]ok = [r["ms"] for r in results if r["ok"]]errors = [r for r in results if not r["ok"]]print("success", len(ok), "errors", len(errors), "median_ms", median(ok) if ok else None)print("error_types", sorted({e["error"] for e in errors}))
Record p50/p95/p99 with an actual percentile library or stable helper, not only median. Repeat at controlled concurrency levels. Pool acquisition failures are valuable evidence: do not “fix” them by removing limits until you know whether the database itself is saturated.
6. Wrong approach: driver creation inside request handler
def endpoint(product_id): with GraphDatabase.driver(URI, auth=AUTH) as driver: # WRONG hot path driver.verify_connectivity() # WRONG per request return driver.execute_query( "MATCH (p:Product {productId:$id}) RETURN p.name", id=product_id, database_="neo4j")
Each request now creates/destroys a pool and may add connectivity handshakes. The repaired service injects one Driver into repositories and creates sessions/transactions only where the unit of work requires them.
Production judgment
| Symptom | Candidate mechanism | Next evidence |
|---|---|---|
| acquisition timeout rises | pool saturated or connections held too long | active request count, result consumption time, server transaction duration |
| many new connections/sec | driver churn or short lifetime/network resets | process lifecycle, pool debug logs, firewall/LB settings |
| query latency okay but API tail high | queue/pool/network/client serialization | client/server timestamp breakdown |
| server overloaded after pool increase | client limit was protecting DB | server CPU/page cache/query concurrency and SLO |
| stale idle sockets | network device closes idle connections | idle age, liveness test experiment, network policy |
Check your understanding
- Is “one session per request” inherently an anti-pattern?
-
Is
max_connection_pool_size=100a global cap? - Why can an acquisition timeout be healthy?
-
Should
verify_connectivity()run before every query? - What should determine connection lifetime?
Review the answers
1. No. Sessions are lightweight and short-lived; one Driver per request or concurrent session reuse is the real lifecycle mistake.
2. No. In the Python driver it is per host; routing drivers may have pools for multiple cluster members.
3. It bounds queueing and exposes saturation instead of creating unbounded load.
4. No. Use it for startup/readiness diagnostics, not as per-request tax.
5. Measured network/server behavior such as load-balancer/firewall limits and connection churn, not an arbitrary universal value.
Summary and next step
Pool settings bound resource acquisition; routing and retry policies decide where work goes and how failures are re-attempted. Lesson 4 combines those mechanisms with bookmarks and request correlation.
Authoritative references
- Current Neo4j versions — Current server and 5.26 LTS release snapshot.
- Neo4j Python Driver Manual — Official application-driver guide used by the mandatory lab.
- Python Driver 6.3 API — Current API, Bolt compatibility and lifecycle contract.
- Driver connection guide — Driver lifetime, connectivity checks and cluster routing.
- Advanced connection information — URI schemes, TLS, resolver and connection configuration.
- Transactions with the Python driver — Session/transaction lifecycle, managed retries and result streaming.
- Python driver performance recommendations — Lazy streaming, fetch size and read routing guidance.
- Bolt compatibility matrix — Neo4j DBMS and negotiated Bolt protocol versions.
- Python driver configuration API — Connection/pool defaults and semantics.
- Query metadata — Attach metadata visible in server logs/transactions.