Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
p7g committed Dec 8, 2020
0 parents commit 6c12e20
Show file tree
Hide file tree
Showing 15 changed files with 977 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[flake8]
max_line_length = 88
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @p7g
69 changes: 69 additions & 0 deletions .github/workflows/build_deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Build

on:
pull_request:
release:
types:
- published

jobs:
build_wheel:
name: Build wheel
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
# Include all history and tags
with:
fetch-depth: 0

- uses: actions/setup-python@v2
name: Install Python
with:
python-version: '3.8'

- name: Build wheel
run: |
python -m pip install wheel
python -m pip wheel -w dist .
- uses: actions/upload-artifact@v2
with:
path: dist/*.whl

build_sdist:
name: Build source distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
# Include all history and tags
with:
fetch-depth: 0

- uses: actions/setup-python@v2
name: Install Python
with:
python-version: '3.8'

- name: Build sdist
run: |
python setup.py sdist
- uses: actions/upload-artifact@v2
with:
path: dist/*.tar.gz

upload_pypi:
needs: [build_wheel, build_sdist]
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- uses: actions/download-artifact@v2
with:
name: artifact
path: dist

- uses: pypa/gh-action-pypi-publish@master
with:
user: __token__
password: ${{ secrets.PYPI_TOKEN }}
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI
on: [push, pull_request]
jobs:
black:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- run: pip install riot==0.4.0
- run: riot -v run -s black -- --check .
mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- run: pip install riot==0.4.0
- run: riot -v run mypy
flake8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- run: pip install riot==0.4.0
- run: riot -v run flake8
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
python-version: [3.8, 3.9]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: install riot
run: pip install riot==0.4.0
- name: run tests
run: riot -v run --python=${{ matrix.python-version }} test
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__pycache__/
.eggs/
*.egg-info/
.mypy_cache/
.riot
29 changes: 29 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
BSD 3-Clause License

Copyright (c) 2019, Fellow Insights Inc.
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
121 changes: 121 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# `asyncio-connection-pool`

This is a generic, high-throughput, optionally-burstable pool for asyncio.

Some cool features:

- No locking aside from the GIL; no `asyncio.Lock` or `asyncio.Condition` needs
to be taken in order to get a connection.
- Available connections are retrieved without yielding to the event loop.
- When `burst_limit` is specified, `max_size` acts as a "soft" limit; the pool
can go beyond this limit to handle increased load, and shrinks back down
after.
- The contents of the pool can be anything; just implement a
`ConnectionStrategy`.


## Why?

We were using a different pool for handling our Redis connections, and noticed
that, under heavy load, we would spend a lot of time waiting for the lock, even
when there were available connections in the pool.

We also thought it would be nice if we didn't need to keep many connections
open when they weren't needed, but still have the ability to more when they are
required.


## API


### `asyncio_connection_pool.ConnectionPool`

This is the implementation of the pool. It is generic over a type of
connection, and all implementation-specific logic is contained within a
[`ConnectionStrategy`](#connectionstrategy).

A pool is created as follows:

```python
from asyncio_connection_pool import ConnectionPool

pool = ConnectionPool(strategy=my_strategy, max_size=15)
```

The constructor can optionally be passed an integer as `burst_limit`. This
allows the pool to open more connections than `max_size` temporarily.


#### `@asynccontextmanager async def get_connection(self) -> AsyncIterator[Conn]`

This method is the only way to get a connection from the pool. It is expected
to be used as follows:

```python
pool = ConnectionPool(...)

async with pool.get_connection() as conn:
# Use the connection
pass
```

When the `async with` block is entered, a connection is retrieved. If a
connection needs to be opened or if the pool is at capacity and no connections
are available, the caller will yield to the event loop.

When the block is exited, the connection will be returned to the pool.


### `asyncio_connection_pool.ConnectionStrategy`

This is an abstract class that defines the interface of the object passed as
`strategy`. A subclass _must_ implement the following methods:


#### `async def create_connection(self) -> Awaitable[Conn]`

This method is called to create a new connection to the resource. This happens
when a connection is requested and all connections are in use, as long as the
pool is not at capacity.

The result of a call to this method is what will be provided to a consumer of
the pool, and in most cases will be stored in the pool to be re-used later.

If this method raises an exception, it will bubble up to the frame where
`ConnectionPool.get_connection()` was called.


#### `def connection_is_closed(self, conn: Conn) -> bool`

This method is called to check if a connection is no longer able to be used.
When the pool is retrieving a connection to give to a client, this method is
called to make sure it is valid.

The return value should be `True` if the connection is _not_ valid.

If this method raises an exception, it is assumed that the connection is
invalid. The passed-in connection is dropped and a new one is retrieved. The
exception is suppressed unless it is not a `BaseException`, like
`asyncio.CancelledError`. It is the responsibility of the `ConnectionStrategy`
implementation to avoid leaking a connection in this case.


#### `def close_connection(self, conn: Conn)`

This method is called to close a connection. This occurs when the pool has
exceeded `max_size` (i.e. it is bursting) and a connection is returned that is
no longer needed (i.e. there are no more consumers waiting for a connection).

Note that this method is synchronous; if closing a connection is an
asynchronous operation, `asyncio.create_task` can be used.

If this method raises an exception, the connection is dropped and the exception
bubbles to the caller of `ConnectionPool.get_connection().__aexit__` (usually
an `async with` block).


## How is this safe without locks?

I encourage you to read the [source](https://github.com/fellowinsights/asyncio-connection-pool/blob/master/asyncio_connection_pool/__init__.py)
to find out (it is quite well-commented). If you notice any faults in the
logic, please feel free to file an issue.
Loading

0 comments on commit 6c12e20

Please sign in to comment.