Skip to content

Commit bb2cb95

Browse files
Merge pull request #3 from NYPL/postgres-pooling
Use postgres connection pooling
2 parents 57f5c58 + bdea305 commit bb2cb95

6 files changed

Lines changed: 136 additions & 61 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Deploy to production if tagged as production release
2+
3+
on:
4+
release:
5+
types: [ released ]
6+
7+
jobs:
8+
check_production_tag:
9+
name: Check if the release is tagged as production
10+
runs-on: ubuntu-latest
11+
outputs:
12+
has_production_tag: ${{ steps.check-production-tag.outputs.run_jobs }}
13+
steps:
14+
- name: check production tag ${{ github.ref }}
15+
id: check-production-tag
16+
run: |
17+
if [[ ${{ github.ref }} =~ refs\/tags\/production ]]; then
18+
echo "run_jobs=true" >> $GITHUB_OUTPUT
19+
else
20+
echo "run_jobs=false" >> $GITHUB_OUTPUT
21+
fi
22+
publish_production:
23+
needs: [ check_production_tag ]
24+
if: needs.check_production_tag.outputs.has_production_tag == 'true'
25+
name: Publish package to pypi.org
26+
runs-on: ubuntu-latest
27+
steps:
28+
- name: Checkout repo
29+
uses: actions/checkout@v3
30+
31+
- name: Set up Python 3.9
32+
uses: actions/setup-python@v4
33+
with:
34+
python-version: '3.9'
35+
cache: 'pip'
36+
cache-dependency-path: 'pyproject.toml'
37+
38+
- name: Install pypa/build
39+
run: >-
40+
python -m
41+
pip install
42+
build
43+
--user
44+
45+
- name: Build a binary wheel and a source tarball
46+
run: >-
47+
python -m
48+
build
49+
--sdist
50+
--wheel
51+
--outdir dist/
52+
.
53+
54+
- name: Publish distribution package to PyPI
55+
uses: pypa/gh-action-pypi-publish@release/v1
56+
with:
57+
password: ${{ secrets.PYPI_API_TOKEN }}

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## v0.0.3 - 2/10
4+
5+
- Added GitHub Actions workflow for deploying to production
6+
- Switched PostgreSQLClient to use connection pooling
7+
38
## v0.0.2 - 2/6
49

510
- Added CODEOWNERS

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ This package contains common Python utility classes and functions.
55
## Classes
66
* Pushing records to Kinesis
77
* Setting and retrieving a resource in S3
8+
* Decrypting values with KMS
89
* Encoding and decoding records using a given Avro schema
910
* Connecting to and querying a MySQL database
1011
* Connecting to and querying a PostgreSQL database
1112
* Connecting to and querying Redshift
1213

1314
## Functions
1415
* Reading a YAML config file and putting the contents in os.environ
15-
* Decrypting a value using KMS
1616
* Creating a logger in the appropriate format
1717
* Obfuscating a value using bcrypt
1818

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ dependencies = [
2323
"botocore>=1.29.5",
2424
"mysql-connector-python>=8.0.32",
2525
"psycopg>=3.1.0",
26-
"psycopg-binary>=3.1.0",
26+
"psycopg-pool>=3.1.6",
2727
"PyYAML>=6.0",
2828
"redshift-connector>=2.0.909",
2929
"requests>=2.28.1"

src/nypl_py_utils/classes/postgresql_client.py

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,35 @@
11
import psycopg
22

33
from nypl_py_utils.functions.log_helper import create_log
4+
from psycopg_pool import ConnectionPool
45

56

67
class PostgreSQLClient:
78
"""
89
Client for managing connections to a PostgreSQL database (such as Sierra)
910
"""
1011

11-
def __init__(self, host, port, db_name, user, password):
12+
def __init__(self, host, port, db_name, user, password, **kwargs):
1213
self.logger = create_log('postgresql_client')
13-
self.conn = None
14-
self.host = host
15-
self.port = port
1614
self.db_name = db_name
17-
self.user = user
18-
self.password = password
15+
self.timeout = kwargs.get('timeout', 300)
16+
17+
conn_info = 'postgresql://{user}:{password}@{host}:{port}/{db_name}'\
18+
.format(user=user, password=password, host=host, port=port,
19+
db_name=db_name)
20+
self.pool = ConnectionPool(
21+
conn_info, open=False,
22+
min_size=kwargs.get('min_size', 1),
23+
max_size=kwargs.get('max_size', None))
1924

2025
def connect(self):
21-
"""Connects to a PostgreSQL database using the given credentials"""
26+
"""
27+
Opens the connection pool and connects to the given PostgreSQL database
28+
min_size number of times.
29+
"""
2230
self.logger.info('Connecting to {} database'.format(self.db_name))
2331
try:
24-
self.conn = psycopg.connect(
25-
host=self.host,
26-
port=self.port,
27-
dbname=self.db_name,
28-
user=self.user,
29-
password=self.password)
32+
self.pool.open(wait=True, timeout=self.timeout)
3033
except psycopg.Error as e:
3134
self.logger.error(
3235
'Error connecting to {name} database: {error}'.format(
@@ -37,32 +40,33 @@ def connect(self):
3740

3841
def execute_query(self, query):
3942
"""
40-
Executes an arbitrary query against the given database connection.
43+
Requests a connection from the pool and uses it to execute an arbitrary
44+
query. After the query is complete, returns the connection to the pool.
4145
4246
Returns a sequence of tuples representing the rows returned by the
4347
query.
4448
"""
4549
self.logger.info('Querying {} database'.format(self.db_name))
4650
self.logger.debug('Executing query {}'.format(query))
47-
try:
48-
cursor = self.conn.cursor()
49-
return cursor.execute(query).fetchall()
50-
except Exception as e:
51-
self.conn.rollback()
52-
self.logger.error(
53-
('Error executing {name} database query \'{query}\': {error}')
54-
.format(name=self.db_name, query=query, error=e))
55-
raise PostgreSQLClientError(
56-
('Error executing {name} database query \'{query}\': {error}')
57-
.format(name=self.db_name, query=query, error=e)) from None
58-
finally:
59-
cursor.close()
51+
with self.pool.connection() as conn:
52+
try:
53+
return conn.execute(query).fetchall()
54+
except Exception as e:
55+
conn.rollback()
56+
self.logger.error(
57+
('Error executing {name} database query \'{query}\': '
58+
'{error}').format(
59+
name=self.db_name, query=query, error=e))
60+
raise PostgreSQLClientError(
61+
('Error executing {name} database query \'{query}\': '
62+
'{error}').format(
63+
name=self.db_name, query=query, error=e)) from None
6064

6165
def close_connection(self):
62-
"""Closes the database connection"""
66+
"""Closes the connection pool"""
6367
self.logger.debug('Closing {} database connection'.format(
6468
self.db_name))
65-
self.conn.close()
69+
self.pool.close()
6670

6771

6872
class PostgreSQLClientError(Exception):

tests/test_postgresql_client.py

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,50 +6,59 @@
66
class TestPostgreSQLClient:
77

88
@pytest.fixture
9-
def mock_pg_conn(self, mocker):
10-
return mocker.patch('psycopg.connect')
11-
12-
@pytest.fixture
13-
def test_instance(self):
9+
def test_instance(self, mocker):
10+
mocker.patch('psycopg_pool.ConnectionPool.open')
11+
mocker.patch('psycopg_pool.ConnectionPool.close')
1412
return PostgreSQLClient('test_host', 'test_port', 'test_db_name',
1513
'test_user', 'test_password')
1614

17-
def test_connect(self, mock_pg_conn, test_instance):
18-
test_instance.connect()
19-
mock_pg_conn.assert_called_once_with(host='test_host',
20-
port='test_port',
21-
dbname='test_db_name',
22-
user='test_user',
23-
password='test_password')
15+
def test_init(self, test_instance):
16+
assert test_instance.pool.conninfo == (
17+
'postgresql://test_user:test_password@test_host:test_port/' +
18+
'test_db_name')
19+
assert test_instance.pool._opened is False
20+
assert test_instance.pool.min_size == 1
21+
assert test_instance.pool.max_size == 1
2422

25-
def test_execute_query(self, mock_pg_conn, test_instance, mocker):
23+
def test_init_with_kwargs(self):
24+
test_instance = PostgreSQLClient(
25+
'test_host', 'test_port', 'test_db_name', 'test_user',
26+
'test_password', min_size=5, max_size=10)
27+
assert test_instance.pool.conninfo == (
28+
'postgresql://test_user:test_password@test_host:test_port/' +
29+
'test_db_name')
30+
assert test_instance.pool._opened is False
31+
assert test_instance.pool.min_size == 5
32+
assert test_instance.pool.max_size == 10
33+
34+
def test_connect(self, test_instance):
2635
test_instance.connect()
36+
test_instance.pool.open.assert_called_once()
2737

28-
mock_cursor = mocker.MagicMock()
29-
mock_cursor.execute.return_value = mock_cursor
30-
mock_cursor.fetchall.return_value = [(1, 2, 3), ('a', 'b', 'c')]
31-
test_instance.conn.cursor.return_value = mock_cursor
38+
def test_connect_with_exception(self):
39+
test_instance = PostgreSQLClient(
40+
'test_host', 'test_port', 'test_db_name', 'test_user',
41+
'test_password', timeout=1.0)
3242

33-
assert test_instance.execute_query(
34-
'test query') == [(1, 2, 3), ('a', 'b', 'c')]
35-
mock_cursor.execute.assert_called_once_with('test query')
36-
mock_cursor.close.assert_called_once()
43+
with pytest.raises(PostgreSQLClientError):
44+
test_instance.connect()
3745

38-
def test_execute_query_with_exception(
39-
self, mock_pg_conn, test_instance, mocker):
46+
def test_execute_query(self, test_instance, mocker):
4047
test_instance.connect()
4148

42-
mock_cursor = mocker.MagicMock()
43-
mock_cursor.execute.side_effect = Exception()
44-
test_instance.conn.cursor.return_value = mock_cursor
49+
mock_conn = mocker.MagicMock()
50+
mock_conn.execute.side_effect = Exception()
51+
mock_conn_context = mocker.MagicMock()
52+
mock_conn_context.__enter__.return_value = mock_conn
53+
mocker.patch('psycopg_pool.ConnectionPool.connection',
54+
return_value=mock_conn_context)
4555

4656
with pytest.raises(PostgreSQLClientError):
4757
test_instance.execute_query('test query')
4858

49-
test_instance.conn.rollback.assert_called_once()
50-
mock_cursor.close.assert_called_once()
59+
mock_conn.rollback.assert_called_once()
5160

52-
def test_close_connection(self, mock_pg_conn, test_instance):
61+
def test_close_connection(self, test_instance):
5362
test_instance.connect()
5463
test_instance.close_connection()
55-
test_instance.conn.close.assert_called_once()
64+
test_instance.pool.close.assert_called_once()

0 commit comments

Comments
 (0)