sql2api 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sql2api-0.1.0/sql2api.egg-info → sql2api-0.2.0}/PKG-INFO +8 -1
- {sql2api-0.1.0 → sql2api-0.2.0}/README.md +7 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/__init__.py +1 -1
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/app.py +23 -4
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/config.py +39 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/engine.py +6 -3
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/openapi.py +4 -1
- sql2api-0.2.0/sql2api/pool.py +146 -0
- sql2api-0.2.0/sql2api/runners.py +354 -0
- {sql2api-0.1.0 → sql2api-0.2.0/sql2api.egg-info}/PKG-INFO +8 -1
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/SOURCES.txt +1 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/tests/test_integration.py +75 -1
- {sql2api-0.1.0 → sql2api-0.2.0}/tests/test_sql2api.py +404 -1
- sql2api-0.1.0/sql2api/runners.py +0 -142
- {sql2api-0.1.0 → sql2api-0.2.0}/LICENSE +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/pyproject.toml +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/setup.cfg +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/__main__.py +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/cli.py +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/errors.py +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/formats.py +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/lib/h2-2.2.224.jar +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/sqltools.py +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/store.py +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/dependency_links.txt +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/entry_points.txt +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/requires.txt +0 -0
- {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql2api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Turn SQL into a REST API: run queries against MySQL, PostgreSQL, ClickHouse, SQLite or H2 over HTTP and get JSON, CSV, XML, YAML or XLSX back.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -75,6 +75,10 @@ film_id,title,rating,length
|
|
|
75
75
|
- **Bound parameters** - write `WHERE id = :id` and the value is sent to the database separately from the SQL, so it
|
|
76
76
|
cannot inject anything. Declare types (`{"id": "int"}`) and query-string values are converted for you.
|
|
77
77
|
- **Pagination** - `?page=2&page_size=50`, with `X-Has-More` telling you whether another page exists.
|
|
78
|
+
- **Connection pooling** - MySQL, PostgreSQL, ClickHouse and H2 connections are reused between requests instead of
|
|
79
|
+
opened for each one (about 30x lower per-request overhead on MySQL and H2 against a local server; more over a network).
|
|
80
|
+
- **Query time limit** - runaway queries are cancelled on the database (30 s by default, `?timeout=` per request) so
|
|
81
|
+
they cannot tie up the service.
|
|
78
82
|
- **Read-only by default** - only single `SELECT`/`WITH`/`SHOW`/`DESCRIBE`/`EXPLAIN` statements run, and sessions are
|
|
79
83
|
opened read-only where the database supports it.
|
|
80
84
|
- **Secrets stay out of files** - `"password": "${PG_PASSWORD}"` in `db_connections.json` reads the environment.
|
|
@@ -136,6 +140,9 @@ Everything is configured through environment variables (all optional):
|
|
|
136
140
|
| `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
|
|
137
141
|
| `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
|
|
138
142
|
| `SQL2API_MAX_PAGE_SIZE` | `1000` | Upper limit for `page_size`. |
|
|
143
|
+
| `SQL2API_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
|
|
144
|
+
| `SQL2API_POOL_IDLE_TIMEOUT` | `300` | Seconds an idle pooled connection is kept before it is closed. |
|
|
145
|
+
| `SQL2API_QUERY_TIMEOUT` | `30` | Seconds a query may run before it is cancelled (HTTP 504). `0` disables the limit. A request can lower it with `?timeout=`, never raise it. |
|
|
139
146
|
| `SQL2API_HOST` / `SQL2API_PORT` | `127.0.0.1` / `5000` | Bind address for `sql2api serve`. |
|
|
140
147
|
| `SQL2API_DEBUG` | off | Flask debug mode. Never enable on a reachable host. |
|
|
141
148
|
| `SQL2API_H2_JAR` | bundled | Path to a different H2 JDBC jar. |
|
|
@@ -34,6 +34,10 @@ film_id,title,rating,length
|
|
|
34
34
|
- **Bound parameters** - write `WHERE id = :id` and the value is sent to the database separately from the SQL, so it
|
|
35
35
|
cannot inject anything. Declare types (`{"id": "int"}`) and query-string values are converted for you.
|
|
36
36
|
- **Pagination** - `?page=2&page_size=50`, with `X-Has-More` telling you whether another page exists.
|
|
37
|
+
- **Connection pooling** - MySQL, PostgreSQL, ClickHouse and H2 connections are reused between requests instead of
|
|
38
|
+
opened for each one (about 30x lower per-request overhead on MySQL and H2 against a local server; more over a network).
|
|
39
|
+
- **Query time limit** - runaway queries are cancelled on the database (30 s by default, `?timeout=` per request) so
|
|
40
|
+
they cannot tie up the service.
|
|
37
41
|
- **Read-only by default** - only single `SELECT`/`WITH`/`SHOW`/`DESCRIBE`/`EXPLAIN` statements run, and sessions are
|
|
38
42
|
opened read-only where the database supports it.
|
|
39
43
|
- **Secrets stay out of files** - `"password": "${PG_PASSWORD}"` in `db_connections.json` reads the environment.
|
|
@@ -95,6 +99,9 @@ Everything is configured through environment variables (all optional):
|
|
|
95
99
|
| `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
|
|
96
100
|
| `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
|
|
97
101
|
| `SQL2API_MAX_PAGE_SIZE` | `1000` | Upper limit for `page_size`. |
|
|
102
|
+
| `SQL2API_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
|
|
103
|
+
| `SQL2API_POOL_IDLE_TIMEOUT` | `300` | Seconds an idle pooled connection is kept before it is closed. |
|
|
104
|
+
| `SQL2API_QUERY_TIMEOUT` | `30` | Seconds a query may run before it is cancelled (HTTP 504). `0` disables the limit. A request can lower it with `?timeout=`, never raise it. |
|
|
98
105
|
| `SQL2API_HOST` / `SQL2API_PORT` | `127.0.0.1` / `5000` | Bind address for `sql2api serve`. |
|
|
99
106
|
| `SQL2API_DEBUG` | off | Flask debug mode. Never enable on a reachable host. |
|
|
100
107
|
| `SQL2API_H2_JAR` | bundled | Path to a different H2 JDBC jar. |
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""The Flask application: HTTP routes on top of the store, engine and formatters."""
|
|
2
2
|
import hmac
|
|
3
3
|
import logging
|
|
4
|
+
import math
|
|
4
5
|
|
|
5
6
|
from flask import Blueprint, Flask, Response, jsonify, redirect, request, url_for
|
|
6
7
|
from flask.json.provider import DefaultJSONProvider
|
|
7
8
|
from werkzeug.exceptions import HTTPException
|
|
8
9
|
|
|
9
|
-
from . import config, engine, openapi, sqltools, store
|
|
10
|
+
from . import config, engine, openapi, pool, sqltools, store
|
|
10
11
|
from .errors import ApiError
|
|
11
12
|
from .formats import FORMATTERS, json_default
|
|
12
13
|
|
|
@@ -14,7 +15,7 @@ log = logging.getLogger('sql2api')
|
|
|
14
15
|
bp = Blueprint('api', __name__)
|
|
15
16
|
|
|
16
17
|
# Query-string arguments that control a request rather than supplying query parameters.
|
|
17
|
-
RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version'}
|
|
18
|
+
RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version', 'timeout'}
|
|
18
19
|
PUBLIC_ENDPOINTS = {'api.index', 'api.favicon', 'api.health', 'api.docs', 'api.openapi_spec'}
|
|
19
20
|
|
|
20
21
|
|
|
@@ -105,6 +106,20 @@ def get_output_format(body=None):
|
|
|
105
106
|
return output_format
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
def get_timeout(body=None):
|
|
110
|
+
"""Seconds allowed for the query: ?timeout= may lower the server limit but never raise it."""
|
|
111
|
+
raw = request.args.get('timeout', (body or {}).get('timeout'))
|
|
112
|
+
if raw in (None, ''):
|
|
113
|
+
return config.effective_timeout(None)
|
|
114
|
+
try:
|
|
115
|
+
value = float(raw)
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
raise ApiError('timeout must be a number of seconds') from None
|
|
118
|
+
if not math.isfinite(value) or value <= 0:
|
|
119
|
+
raise ApiError('timeout must be a positive number of seconds')
|
|
120
|
+
return config.effective_timeout(value)
|
|
121
|
+
|
|
122
|
+
|
|
108
123
|
def render(result, output_format, page, page_size):
|
|
109
124
|
response = jsonify({'message': 'No results returned'}) if not result else FORMATTERS[output_format](result)
|
|
110
125
|
response.headers['X-Page'] = str(page)
|
|
@@ -126,8 +141,9 @@ def execute_sql_endpoint():
|
|
|
126
141
|
raise ApiError('Connection name is missing')
|
|
127
142
|
params = get_object(data.get('params'), 'params')
|
|
128
143
|
output_format = get_output_format(data)
|
|
144
|
+
timeout = get_timeout(data)
|
|
129
145
|
limit, offset, page = get_pagination()
|
|
130
|
-
result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params)
|
|
146
|
+
result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params, timeout)
|
|
131
147
|
return render(result, output_format, page, limit)
|
|
132
148
|
|
|
133
149
|
|
|
@@ -148,11 +164,12 @@ def run_saved(ref, body, url_params):
|
|
|
148
164
|
params = sqltools.coerce_params(saved.get('query_parameters'), raw)
|
|
149
165
|
sql = sqltools.fill_placeholders(saved['sql_query'], params)
|
|
150
166
|
output_format = get_output_format(body)
|
|
167
|
+
timeout = get_timeout(body)
|
|
151
168
|
limit, offset, page = get_pagination()
|
|
152
169
|
|
|
153
170
|
entry = {'executed_at': store.now(), 'connection_name': connection_name}
|
|
154
171
|
try:
|
|
155
|
-
result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, params)
|
|
172
|
+
result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, params, timeout)
|
|
156
173
|
except ApiError as error:
|
|
157
174
|
store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message})
|
|
158
175
|
raise
|
|
@@ -256,12 +273,14 @@ def update_connections():
|
|
|
256
273
|
if not connections or not isinstance(connections, dict):
|
|
257
274
|
raise ApiError('Connections data is missing')
|
|
258
275
|
store.update_connections(connections)
|
|
276
|
+
pool.close_pooled_connections() # new settings or credentials must not be served by old connections
|
|
259
277
|
return jsonify({'message': 'Connections updated successfully'}), 200
|
|
260
278
|
|
|
261
279
|
|
|
262
280
|
@bp.route('/connections/<name>', methods=['DELETE'])
|
|
263
281
|
def delete_connection(name):
|
|
264
282
|
store.delete_connection(name)
|
|
283
|
+
pool.close_pooled_connections() # a removed connection must not keep serving from idle sockets
|
|
265
284
|
return jsonify({'message': f"Connection '{name}' deleted"}), 200
|
|
266
285
|
|
|
267
286
|
|
|
@@ -6,6 +6,9 @@ SUPPORTED_DB_TYPES = ('mysql', 'postgres', 'clickhouse', 'sqlite', 'h2')
|
|
|
6
6
|
PASSWORD_MASK = '********'
|
|
7
7
|
CONNECT_TIMEOUT = 10 # seconds
|
|
8
8
|
HISTORY_LIMIT = 50 # executions remembered per saved-query version
|
|
9
|
+
DEFAULT_QUERY_TIMEOUT = 30.0 # seconds
|
|
10
|
+
DEFAULT_POOL_SIZE = 5 # idle connections kept per distinct connection
|
|
11
|
+
DEFAULT_POOL_IDLE_TIMEOUT = 300.0 # seconds
|
|
9
12
|
|
|
10
13
|
# Ships with the package; override with SQL2API_H2_JAR to use a different H2 version.
|
|
11
14
|
BUNDLED_H2_JAR = Path(__file__).parent / 'lib' / 'h2-2.2.224.jar'
|
|
@@ -55,6 +58,42 @@ def api_key():
|
|
|
55
58
|
return os.environ.get('SQL2API_API_KEY') or None
|
|
56
59
|
|
|
57
60
|
|
|
61
|
+
def query_timeout():
|
|
62
|
+
"""Server-wide statement time limit in seconds (SQL2API_QUERY_TIMEOUT); None when disabled (set to 0)."""
|
|
63
|
+
try:
|
|
64
|
+
value = float(os.environ.get('SQL2API_QUERY_TIMEOUT', DEFAULT_QUERY_TIMEOUT))
|
|
65
|
+
except ValueError:
|
|
66
|
+
return DEFAULT_QUERY_TIMEOUT
|
|
67
|
+
if value == 0:
|
|
68
|
+
return None
|
|
69
|
+
return value if value > 0 else DEFAULT_QUERY_TIMEOUT
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def effective_timeout(requested=None):
|
|
73
|
+
"""The limit to enforce: a request may ask for less time than the server allows, never more."""
|
|
74
|
+
limit = query_timeout()
|
|
75
|
+
if requested is None:
|
|
76
|
+
return limit
|
|
77
|
+
return requested if limit is None else min(requested, limit)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pool_size():
|
|
81
|
+
"""Idle connections kept per distinct connection (SQL2API_POOL_SIZE); 0 disables pooling."""
|
|
82
|
+
try:
|
|
83
|
+
return max(0, int(os.environ.get('SQL2API_POOL_SIZE', DEFAULT_POOL_SIZE)))
|
|
84
|
+
except ValueError:
|
|
85
|
+
return DEFAULT_POOL_SIZE
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def pool_idle_timeout():
|
|
89
|
+
"""Seconds an idle pooled connection is kept before it is closed (SQL2API_POOL_IDLE_TIMEOUT)."""
|
|
90
|
+
try:
|
|
91
|
+
value = float(os.environ.get('SQL2API_POOL_IDLE_TIMEOUT', DEFAULT_POOL_IDLE_TIMEOUT))
|
|
92
|
+
except ValueError:
|
|
93
|
+
return DEFAULT_POOL_IDLE_TIMEOUT
|
|
94
|
+
return value if value > 0 else DEFAULT_POOL_IDLE_TIMEOUT
|
|
95
|
+
|
|
96
|
+
|
|
58
97
|
def max_page_size():
|
|
59
98
|
try:
|
|
60
99
|
return max(1, int(os.environ.get('SQL2API_MAX_PAGE_SIZE', 1000)))
|
|
@@ -5,19 +5,22 @@ import time
|
|
|
5
5
|
from . import config, store
|
|
6
6
|
from .errors import ApiError
|
|
7
7
|
from .formats import ResultSetDTO
|
|
8
|
+
from .pool import get_pool
|
|
8
9
|
from .runners import RUNNERS
|
|
9
10
|
from .sqltools import validate_sql
|
|
10
11
|
|
|
11
12
|
log = logging.getLogger('sql2api')
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
def execute_sql(sql, connection_name, limit, offset, params=None):
|
|
15
|
+
def execute_sql(sql, connection_name, limit, offset, params=None, timeout=None):
|
|
15
16
|
"""Run ``sql`` on a named connection and return the requested page as a ResultSetDTO."""
|
|
16
17
|
details = store.get_connection(connection_name)
|
|
17
18
|
sql = validate_sql(sql)
|
|
18
|
-
log.info('Executing on %s (%s), limit=%s offset=%s: %s',
|
|
19
|
+
log.info('Executing on %s (%s), limit=%s offset=%s timeout=%s: %s',
|
|
20
|
+
connection_name, details['db'], limit, offset, timeout, sql)
|
|
19
21
|
try:
|
|
20
|
-
columns, rows = RUNNERS[details['db']](details, sql, params, limit, offset, not config.allow_writes()
|
|
22
|
+
columns, rows = RUNNERS[details['db']](details, sql, params, limit, offset, not config.allow_writes(), timeout,
|
|
23
|
+
get_pool())
|
|
21
24
|
except ApiError:
|
|
22
25
|
raise
|
|
23
26
|
except ImportError as error:
|
|
@@ -7,12 +7,15 @@ _PAGE_PARAMS = [
|
|
|
7
7
|
{'name': 'page', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 1}},
|
|
8
8
|
{'name': 'page_size', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 10},
|
|
9
9
|
'description': 'Upper limit is SQL2API_MAX_PAGE_SIZE (default 1000).'},
|
|
10
|
+
{'name': 'timeout', 'in': 'query', 'schema': {'type': 'number', 'exclusiveMinimum': 0},
|
|
11
|
+
'description': 'Seconds the query may run before it is cancelled (504). Can lower, never raise, the server '
|
|
12
|
+
'limit SQL2API_QUERY_TIMEOUT (default 30; 0 disables the server limit).'},
|
|
10
13
|
]
|
|
11
14
|
_ROWS = {'description': 'The requested page. Header X-Has-More says whether another page follows.',
|
|
12
15
|
'headers': {'X-Page': {'schema': {'type': 'integer'}}, 'X-Page-Size': {'schema': {'type': 'integer'}},
|
|
13
16
|
'X-Has-More': {'schema': {'type': 'string', 'enum': ['true', 'false']}}},
|
|
14
17
|
'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'object'}}}}}
|
|
15
|
-
_ERRORS = {c: {'$ref': '#/components/responses/Error'} for c in ('400', '401', '403', '404', '500')}
|
|
18
|
+
_ERRORS = {c: {'$ref': '#/components/responses/Error'} for c in ('400', '401', '403', '404', '500', '504')}
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
def _body(properties, required):
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""A small thread-safe pool of live database connections.
|
|
2
|
+
|
|
3
|
+
Only *idle* connections are pooled: a connection is checked out exclusively by one request, and on the way
|
|
4
|
+
back it is reset (its transaction ended) and kept for reuse - unless the request failed, in which case its
|
|
5
|
+
state is unknown and it is closed instead. The number of concurrent connections is not limited here; the
|
|
6
|
+
pool only bounds how many idle ones are kept around.
|
|
7
|
+
"""
|
|
8
|
+
import atexit
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
|
|
17
|
+
from . import config
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger('sql2api')
|
|
20
|
+
|
|
21
|
+
# A connection that has been idle for less than this is trusted without a round trip to check it.
|
|
22
|
+
VALIDATE_AFTER = 5.0 # seconds
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Session:
|
|
26
|
+
"""A connection plus what the driver wants to remember about it (e.g. limits already applied)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, conn, driver):
|
|
29
|
+
self.conn = conn
|
|
30
|
+
self.driver = driver
|
|
31
|
+
self.state = {}
|
|
32
|
+
self.idle_since = time.monotonic()
|
|
33
|
+
|
|
34
|
+
def close(self):
|
|
35
|
+
try:
|
|
36
|
+
self.driver.close(self)
|
|
37
|
+
except Exception: # a connection that cannot be closed cleanly is gone anyway
|
|
38
|
+
log.debug('Error while closing a connection', exc_info=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fingerprint(details, read_only):
|
|
42
|
+
"""Pool key: connections are interchangeable when their settings and read-only mode are identical."""
|
|
43
|
+
blob = json.dumps(details, sort_keys=True, default=str)
|
|
44
|
+
return hashlib.sha256(blob.encode('utf-8')).hexdigest(), bool(read_only)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConnectionPool:
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self._idle = {} # key -> deque of Sessions, least recently used on the left
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def checkout(self, driver, details, read_only):
|
|
54
|
+
key = fingerprint(details, read_only)
|
|
55
|
+
session = self._acquire(key)
|
|
56
|
+
if session is None:
|
|
57
|
+
session = Session(driver.connect(details, read_only), driver)
|
|
58
|
+
try:
|
|
59
|
+
yield session
|
|
60
|
+
except BaseException:
|
|
61
|
+
session.close() # after an error its state is unknown - never reuse it
|
|
62
|
+
raise
|
|
63
|
+
self._release(key, session)
|
|
64
|
+
|
|
65
|
+
# ---- internals ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _acquire(self, key):
|
|
68
|
+
while True:
|
|
69
|
+
with self._lock:
|
|
70
|
+
queue = self._idle.get(key)
|
|
71
|
+
if not queue:
|
|
72
|
+
return None
|
|
73
|
+
session = queue.pop() # most recently used first, so spare connections age out
|
|
74
|
+
if not queue:
|
|
75
|
+
del self._idle[key]
|
|
76
|
+
idle_for = time.monotonic() - session.idle_since
|
|
77
|
+
if idle_for > config.pool_idle_timeout():
|
|
78
|
+
session.close()
|
|
79
|
+
continue
|
|
80
|
+
if idle_for > VALIDATE_AFTER:
|
|
81
|
+
try:
|
|
82
|
+
alive = session.driver.is_alive(session)
|
|
83
|
+
except Exception:
|
|
84
|
+
alive = False
|
|
85
|
+
if not alive:
|
|
86
|
+
log.info('Discarding a dead pooled connection')
|
|
87
|
+
session.close()
|
|
88
|
+
continue
|
|
89
|
+
return session
|
|
90
|
+
|
|
91
|
+
def _release(self, key, session):
|
|
92
|
+
try:
|
|
93
|
+
session.driver.reset(session) # ends the transaction so the next user sees fresh data
|
|
94
|
+
except Exception:
|
|
95
|
+
log.debug('Could not reset a connection; closing it', exc_info=True)
|
|
96
|
+
session.close()
|
|
97
|
+
return
|
|
98
|
+
session.idle_since = time.monotonic()
|
|
99
|
+
surplus = []
|
|
100
|
+
with self._lock:
|
|
101
|
+
queue = self._idle.setdefault(key, deque())
|
|
102
|
+
queue.append(session)
|
|
103
|
+
while len(queue) > config.pool_size():
|
|
104
|
+
surplus.append(queue.popleft())
|
|
105
|
+
surplus.extend(self._expired_locked())
|
|
106
|
+
for old in surplus:
|
|
107
|
+
old.close()
|
|
108
|
+
|
|
109
|
+
def _expired_locked(self):
|
|
110
|
+
"""Remove and return idle sessions past the idle timeout, across every key (caller holds the lock)."""
|
|
111
|
+
cutoff = time.monotonic() - config.pool_idle_timeout()
|
|
112
|
+
expired = []
|
|
113
|
+
for key in list(self._idle):
|
|
114
|
+
queue = self._idle[key]
|
|
115
|
+
while queue and queue[0].idle_since < cutoff:
|
|
116
|
+
expired.append(queue.popleft())
|
|
117
|
+
if not queue:
|
|
118
|
+
del self._idle[key]
|
|
119
|
+
return expired
|
|
120
|
+
|
|
121
|
+
# ---- housekeeping ---------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def idle_count(self):
|
|
124
|
+
with self._lock:
|
|
125
|
+
return sum(len(q) for q in self._idle.values())
|
|
126
|
+
|
|
127
|
+
def close_all(self):
|
|
128
|
+
with self._lock:
|
|
129
|
+
sessions = [s for queue in self._idle.values() for s in queue]
|
|
130
|
+
self._idle.clear()
|
|
131
|
+
for session in sessions:
|
|
132
|
+
session.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_default = ConnectionPool()
|
|
136
|
+
atexit.register(_default.close_all)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def close_pooled_connections():
|
|
140
|
+
"""Close every idle connection in the shared pool (also runs at interpreter exit)."""
|
|
141
|
+
_default.close_all()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_pool():
|
|
145
|
+
"""The shared pool, or None when pooling is disabled (SQL2API_POOL_SIZE=0)."""
|
|
146
|
+
return _default if config.pool_size() > 0 else None
|