Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

use anyio in "core" psycopg #146

Closed
wants to merge 6 commits into from
Closed

use anyio in "core" psycopg #146

wants to merge 6 commits into from

Conversation

dlax
Copy link
Contributor

@dlax dlax commented Nov 11, 2021

This adds support for anyio in psycopg "core" (i.e. not the psycopg_pool) through an AnyIOConnection class, associated waiting functions and a custom AnyIOLibpqWriter implementation.

This uses conditional imports and pulls no explicit dependency on anyio.

The test suite now uses anyio pytest plugin, instead of pytest-asyncio (this change has beed submitted as another PR #352, which this one is based on).

ticket #29

This was referenced Nov 11, 2021
@dvarrazzo
Copy link
Member

dvarrazzo commented Nov 11, 2021

What is the advantage of using anyio?

Last time I checked anyio it was adding a lot of overhead. Can we see flame graphs to show what changes during querying and during copy?

It wouldn't be bad to put up something to create them automatically actually. When I play with them they were a great guide to improve performances.

Ref: https://www.varrazzo.com/blog/2020/05/19/a-trip-into-optimisation/

@dlax
Copy link
Contributor Author

dlax commented Nov 11, 2021

What is the advantage of using anyio?

The advantage is that we have a single implementation on our side that's independent of the underlying async library (asyncio or trio or anything that anyio might support in the future). For users, it works transparently: they can use asyncio.run(main()) or trio.run(main).

Last time I checked anyio it was adding a lot of overhead.

I haven't checked.

But... FastAPI recently moved to anyio as well (https://github.com/tiangolo/fastapi/releases/tag/0.69.0), following starlette move https://www.starlette.io/release-notes/#0150
I suppose this would not have happened if there were performance issues (encode/starlette#1157 mentions a 4.5% drop in performance).

Can we see flame graphs to show what changes during querying and during copy?

It wouldn't be bad to put up something to create them automatically actually. When I play with them they were a great guide to improve performances.

Ref: https://www.varrazzo.com/blog/2020/05/19/a-trip-into-optimisation/

That would be very helpful, indeed.

@dlax
Copy link
Contributor Author

dlax commented Nov 12, 2021

I have run MagickStack's benchmark against 3.0.3 and this branch. The source code of the benchmark tool handling psycopg 3 is available at https://github.com/dlax/pgbench (branch psycopg).

Running ./pgbench python-psycopg-async, on master (tag 3.0.3):

Initializing temporary PostgreSQL cluster...
Running python-psycopg-async benchmarks...
==========================================

1-pg_type.json, concurrency 10
------------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=57458 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/1-pg_type.json
12833 queries in 30.02 seconds
Latency: min 10.25ms; max 28.65ms; mean 23.369ms; std: 0.421ms (1.8%)
Latency distribution: 25% under 23.226ms; 50% under 23.34ms; 75% under 23.47ms; 90% under 23.611ms; 99% under 24.637ms; 99.99% under 28.104ms
Queries/sec: 427.5
Rows/sec: 256073.72

2-generate_series.json, concurrency 10
--------------------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=57458 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/2-generate_series.json
22759 queries in 30.01 seconds
Latency: min 3.9ms; max 50.88ms; mean 13.171ms; std: 4.914ms (37.31%)
Latency distribution: 25% under 9.371ms; 50% under 13.162ms; 75% under 13.431ms; 90% under 17.753ms; 99% under 31.327ms; 99.99% under 49.834ms
Queries/sec: 758.36
Rows/sec: 758357.32

3-large_object.json, concurrency 10
-----------------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=57458 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/3-large_object.json
24550 queries in 30.01 seconds
Latency: min 7.2ms; max 24.93ms; mean 12.207ms; std: 1.226ms (10.04%)
Latency distribution: 25% under 12.262ms; 50% under 12.352ms; 75% under 12.46ms; 90% under 12.788ms; 99% under 14.929ms; 99.99% under 22.861ms
Queries/sec: 818.11
Rows/sec: 81810.64

4-arrays.json, concurrency 10
-----------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=57458 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/4-arrays.json
3706 queries in 30.07 seconds
Latency: min 13.31ms; max 119.47ms; mean 81.05ms; std: 13.833ms (17.07%)
Latency distribution: 25% under 74.269ms; 50% under 75.136ms; 75% under 76.479ms; 90% under 111.65ms; 99% under 115.749ms; 99.99% under 119.473ms
Queries/sec: 123.23
Rows/sec: 12322.83

5-copyfrom.json, concurrency 10
-------------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=57458 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/5-copyfrom.json
11710000
956 queries in 30.62 seconds
Latency: min 108.04ms; max 1844.63ms; mean 305.438ms; std: 111.382ms (36.47%)
Latency distribution: 25% under 251.7ms; 50% under 290.71ms; 75% under 329.63ms; 90% under 396.308ms; 99% under 535.209ms; 99.99% under 1844.638ms
Queries/sec: 31.22
Rows/sec: 314449.84

6-batch.json, concurrency 10
----------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=57458 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/6-batch.json
130 queries in 30.39 seconds
Latency: min 731.93ms; max 1799.65ms; mean 1186.183ms; std: 357.06ms (30.1%)
Latency distribution: 25% under 773.54ms; 50% under 1201.73ms; 75% under 1568.21ms; 90% under 1695.09ms; 99% under 1765.544ms; 99.99% under 1799.66ms
Queries/sec: 4.28
Rows/sec: 5922.59

Geometric mean results
----------------------

3858.99 queries in 30.19 seconds
Latency: min 25.915ms; max 156.0ms; mean 69.255ms; std: 10.571ms (15.26%)
Latency distribution: 25% under 58.132ms; 50% under 68.084ms; 75% under 73.314ms; 90% under 85.91ms; 99% under 103.93ms; 99.99% under 152.743ms
Queries/sec: 127.85
Rows/sec: 84522.12

And then, on this branch, with anyio:

Initializing temporary PostgreSQL cluster...
Running python-psycopg-async benchmarks...
==========================================

1-pg_type.json, concurrency 10
------------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=63536 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/1-pg_type.json
22539 queries in 30.01 seconds
Latency: min 5.75ms; max 27.48ms; mean 13.306ms; std: 1.263ms (9.49%)
Latency distribution: 25% under 12.699ms; 50% under 12.937ms; 75% under 13.872ms; 90% under 14.229ms; 99% under 17.172ms; 99.99% under 27.275ms
Queries/sec: 751.03
Rows/sec: 449868.48

2-generate_series.json, concurrency 10
--------------------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=63536 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/2-generate_series.json
33502 queries in 30.02 seconds
Latency: min 2.95ms; max 29.62ms; mean 8.953ms; std: 3.055ms (34.12%)
Latency distribution: 25% under 8.034ms; 50% under 8.218ms; 75% under 8.345ms; 90% under 8.733ms; 99% under 21.577ms; 99.99% under 27.413ms
Queries/sec: 1116.0
Rows/sec: 1116004.02

3-large_object.json, concurrency 10
-----------------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=63536 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/3-large_object.json
19780 queries in 30.0 seconds
Latency: min 11.46ms; max 23.31ms; mean 15.16ms; std: 0.659ms (4.35%)
Latency distribution: 25% under 14.78ms; 50% under 14.893ms; 75% under 15.619ms; 90% under 15.949ms; 99% under 17.089ms; 99.99% under 23.19ms
Queries/sec: 659.24
Rows/sec: 65923.6

4-arrays.json, concurrency 10
-----------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=63536 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/4-arrays.json
3218 queries in 30.09 seconds
Latency: min 11.86ms; max 98.6ms; mean 93.365ms; std: 2.913ms (3.12%)
Latency distribution: 25% under 92.681ms; 50% under 93.458ms; 75% under 94.27ms; 90% under 94.986ms; 99% under 96.259ms; 99.99% under 98.604ms
Queries/sec: 106.95
Rows/sec: 10694.72

5-copyfrom.json, concurrency 10
-------------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=63536 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/5-copyfrom.json
880 queries in 30.28 seconds
Latency: min 229.69ms; max 617.65ms; mean 343.868ms; std: 32.653ms (9.5%)
Latency distribution: 25% under 333.63ms; 50% under 337.943ms; 75% under 345.45ms; 90% under 353.507ms; 99% under 542.424ms; 99.99% under 617.658ms
Queries/sec: 29.06
Rows/sec: 290612.64

6-batch.json, concurrency 10
----------------------------

/home/denis/src/pgbench/pgbench_python --warmup-time=5 --duration=30 --timeout=2 --pghost=/tmp --pgport=63536 --pguser=postgres --output-format=json --concurrency=10 psycopg-async /home/denis/src/pgbench/queries/6-batch.json
160 queries in 31.37 seconds
Latency: min 1595.57ms; max 1897.04ms; mean 1698.134ms; std: 94.568ms (5.57%)
Latency distribution: 25% under 1615.24ms; 50% under 1650.63ms; 75% under 1790.2ms; 90% under 1846.58ms; 99% under 1895.788ms; 99.99% under 1897.05ms
Queries/sec: 5.1
Rows/sec: 5738.42

Geometric mean results
----------------------

4349.14 queries in 30.29 seconds
Latency: min 30.747ms; max 113.974ms; mean 67.953ms; std: 5.328ms (7.84%)
Latency distribution: 25% under 64.985ms; 50% under 65.986ms; 75% under 68.73ms; 90% under 70.51ms; 99% under 92.509ms; 99.99% under 112.277ms
Queries/sec: 143.57
Rows/sec: 91589.3

@dvarrazzo
Copy link
Member

dvarrazzo commented Nov 12, 2021

I have run MagickStack's benchmark against 3.0.3 and this branch. The source code of the benchmark tool handling psycopg 3 is available at https://github.com/dlax/pgbench (branch psycopg).

Why is your branch using internal functions instead of the public interface?

About 1y ago I made a psycopg3 pgbench branch too: https://github.com/dvarrazzo/pgbench/tree/psycopg3. I don't know if it's still good or if it's rotten.

Running ./pgbench python-psycopg-async, on master (tag 3.0.3):

Queries/sec: 427.5
Queries/sec: 758.36
Queries/sec: 818.11
Queries/sec: 123.23
Queries/sec: 31.22
Queries/sec: 4.28
avg
Queries/sec: 127.85

And then, on this branch, with anyio:
Queries/sec: 751.03
Queries/sec: 1116.0
Queries/sec: 659.24
Queries/sec: 106.95
Queries/sec: 29.06
Queries/sec: 5.1
avg
Queries/sec: 143.57

Curious results, no? Do tests hit different paths, so that anyio is faster on the first two and slower on the other ones? Or am I reading something wrong?

Without changing too much, but only dropping in your Futures patch in the 3.0.3 wait loop, how does it compare?

Thank you for this!

@dlax
Copy link
Contributor Author

dlax commented Nov 12, 2021

Why is your branch using internal functions instead of the public interface?

You mean psycopg.pq? Yes I added benchmark runners implemented in this low-level API (benchmark names: python-pq and python-pq-async to compare with the normal psycopg API (benchmark names: python-psycopg and python-psycopg-async).
But here, I'm using the normal API (benchmark: python-psycopg-async).

About 1y ago I made a psycopg3 pgbench branch too: https://github.com/dvarrazzo/pgbench/tree/psycopg3. I don't know if it's still good or if it's rotten.

Didn't know about this; it's close to my branch, only the "copy" benchmarks seem different (you used an io.StringIO(), I used a loop with copy.write_row()).

How about we coordinate with a common fork in psycopg organization?
At some point, having a benchmark suite run automatically and following the git history would be really nice. Perhaps using asv?

Curious results, no?

Yeah, that looked strange to me as well. But I did not investigate much yet.

Without changing too much, but only dropping in your Futures patch in the 3.0.3 wait loop, how does it compare?

Good idea, will do.

@dlax
Copy link
Contributor Author

dlax commented Nov 15, 2021

Curious results, no? Do tests hit different paths, so that anyio is faster on the first two and slower on the other ones? Or am I reading something wrong?

In fact, I cannot get reliable results with this benchmarks. For instance, running them twice on 3.0.3 tag, I get:

Queries/sec: 1266.77
Queries/sec: 1242.04
Queries/sec: 1277.79
Queries/sec: 112.31
Queries/sec: 29.83
Queries/sec: 8.16
Queries/sec: 194.99

and

Queries/sec: 597.51
Queries/sec: 1094.14
Queries/sec: 1664.3
Queries/sec: 106.7
Queries/sec: 31.63
Queries/sec: 8.64
Queries/sec: 177.93

Even running each benchmark alone does not produce consistent results... That's all I can tell with the hardware I have.

Without changing too much, but only dropping in your Futures patch in the 3.0.3 wait loop, how does it compare?

I did not observe any speedup with the Future-based waiting loop (see commit dlax/psycopg@4630e68) rather a slight slowdown actually. In addition to the above benchmarks (and their inconsistency), I ran the "pipeline demo" test (https://github.com/psycopg/psycopg/pull/116/files#diff-7c5c69215e01c35a4a21c087ec14a2581abf18209386d7522b685ca8af20961dR46, takes about 2.5s to complete on my machine) as a benchmark with the Event-based and Future-based loop: no obvious winner/looser there either...

@dlax
Copy link
Contributor Author

dlax commented Nov 26, 2021

Reworked this to make the dependency on anyio optional.

(Still needs some work on copy.)

@dlax dlax force-pushed the anyio/core branch 9 times, most recently from 7632f8e to 597c1cf Compare December 21, 2021 10:20
@dlax dlax marked this pull request as ready for review December 21, 2021 10:59
@dlax
Copy link
Contributor Author

dlax commented Dec 21, 2021

tests/types/test_net.py::test_lazy_load fails in many cases in CI; not sure how it's related.

@dvarrazzo
Copy link
Member

tests/types/test_net.py::test_lazy_load fails in many cases in CI; not sure how it's related.

Probably anyio uses that module. The test runs with the assumption that the network module is not imported when psycopg is.

@dlax
Copy link
Contributor Author

dlax commented Dec 21, 2021

I see, makes sense; thanks.
I'll see how to adjust this test then.

@Kludex
Copy link

Kludex commented Jun 22, 2022

I understand why this PR was created... But does it still make sense to have anyio now that it's wanted for it to be optional dependency? 🤔

@dlax
Copy link
Contributor Author

dlax commented Jan 27, 2023

I've reworked this PR to make it as minimal as possible by:

  • removing the custom copy-writer class (similar to AsyncQueuedLibpqWriter, but using streams), as the default AsyncLibpqWriter works fine with anyio/trio (previous implementation here);
  • keeping concurrency tests (which use asyncio in their implementation) only run with asyncio backend (as I don't think porting them to also run the trio backend would bring much coverage w.r.t. to the churn).

So should be easier to review now...

dlax and others added 6 commits February 4, 2023 16:56
All references to asyncio in comments are turned into a more generic
'async libraries' term.
In preparation for adding support for anyio as asynchronous I/O library,
this makes it clearer that these functions are asyncio-specific.
This is to support other async libraries than asyncio.

Functions fake_resolve() and fail_resolve() in conninfo tests are no
longer fixtures monkeypatching asyncio but simply implement a fake
getaddrinfo(). On the other hand, fake_resolve() in
test_connection_async.py (previously imported from test_conninfo.py)
still monkeypatches asyncio as this is an indirect test for
resolve_hostaddr_async().
Trio will raise this ResourceWarning:

    Async generator 'psycopg.cursor_async.AsyncCursor.__aiter__' was
    garbage collected before it had been e xhausted. Surround its use in
    'async with aclosing(...):' to ensure that it gets cleaned up as
    soon as you're done using it.

So we explicitly close async generators when we're partially consuming
them (because of a 'break' in the loop).
This adds a new AnyIOConnection class to be used instead of
AsyncConnection in combination with the 'anyio' async library. The class
essentially uses an anyio.Lock instead of an asyncio.Lock (and same for
getaddrinfo()) and relies on AnyIO-specific waiting functions, also
introduced here. The same is done for crdb connection class, though with
more repetition due to typing issues mentioned in inline comments.

All anyio-related code lives in the _anyio sub-package. An 'anyio'
setuptools extra is defined to pull required dependencies.

AnyIOConnection is exposed on the psycopg namespace, a runtime
check is performed when instantiating possibly producing an informative
message about missing dependencies.

In tests, overall, the previous anyio_backend fixture is now
parametrized with both asyncio and trio backends and 'aconn_cls' returns
either AsyncConnection or AnyIOConnection depending on backend name.
Test dependencies now include 'anyio[trio]'.

In "waiting" tests, we define 'wait_{conn_,}_async' fixtures that will
pick either asyncio or anyio waiting functions depending on the value of
'anyio_backend' fixture.

Concurrency tests (e.g. test_concurrency_async.py or respective crdb
ones) are not run with the trio backend as then explicitly use asyncio
API. Porting them does not seem strictly needed, at least now. So they
get marked with asyncio_backend.

Finally, we ignore an invalid error (raised by a deprecation warning
during test) about usage of the 'loop' parameter in asyncio API that is
due to Python bug as mentioned in comment.
We add a --anyio={asyncio,trio} option to pytest to select the AnyIO
backend to run async tests. When this option is set, we'll use the AnyIO
implementations of psycopg API (i.e. AnyIOConnection, resp. waiting
functions, etc.); otherwise (the default), we'll use plain asyncio
implementations (i.e. AsyncConnection).

This way, we can now run the test suite with:
- plain asyncio implementations (previously achieved through the asyncio
  backend for AnyIO pytest plugin),
- AnyIO implementations, with asyncio backend (new from this commit),
- AnyIO implementations, with trio backend (previously achieved through
  the Trio backend for AnyIO pytest plugin).

Accordingly, the anyio_backend fixture is no longer parametrized and its
value depends on the --anyio option.

Selection of whether to use AnyIO or plain asyncio implementations is
done through the new 'use_anyio' fixture, which simply detects if no
--anyio option got specified.

This new fixture is used everywhere we used anyio_backend_name fixture
previously to select either plain asyncio or anyio variants of test
cases. The fixture pulls (but does not use) anyio_backend so that all
tests using it will be detected as async.

In CI, we add a new pytest_opts axis to the matrix to cover all
configurations in various environments.
@dlax dlax marked this pull request as ready for review February 5, 2023 07:42
@saolof
Copy link

saolof commented Apr 1, 2023

What is the status on this? The one test that failed seems to be a PipelineAborted exception? Is it possible to rerun the tests?

As a user, psycopg3 / sqlalchemy is the single library that I am most interested in seeing have an anyio backend / use structured concurrency.

@saolof
Copy link

saolof commented Jun 8, 2023

What happened to this? This was a really nice PR that I was excited about

@dlax
Copy link
Contributor Author

dlax commented Oct 11, 2023

@dvarrazzo, can we make a decision about this? (The lack of response makes me feel you are not prone to integrate the feature but maybe not? I'd be a bit sad to give up on this work, sure; but staying in limbo is worse.)

@dvarrazzo
Copy link
Member

Hello @dlax

I believe I have lost the ball on this: trying to figure out the advantage (because I don't know much about the frameworks outside asyncio). I think I was waiting on a followup about the inconsistencies in the benchmark.

Let's not consider this closed.

@dvarrazzo
Copy link
Member

Hello @dlax

of course this branch now has several conflict with master, and I have no problem to help rebasing it, if we think it's the right thing to do - as I may know better where things have gone, to which aisle of the supermarket the tuna was moved, etc. It's also not fair on your work so I'll give you a hand with it.

To be honest, adding a core dependency on anyio concerns me. Recently, the release of anyio 4 changed interfaces, broke tests, and I had to pin it to < 4 (e847f3c) until dropping Python 3.7. Now that 3.7 is gone we can move to use >= 4 instead; opened #662 to remember to address that.

As such I would like to decouple a release cycle that anyio or trio might impose to an optional component that can follow better the release cycle of the components it depends on. This was the same choice made for the connection pool.

So what I propose is to follow the same pattern used with the pool, so that people can pip install psycopg[anyio], which would install a psycopg_anyio and its dependencies.

Now, I see that anyio has a concept of global backend, which means that we don't expect people to use both AsyncConnection and AnyIOConnection in the same program. The two objects are only different pretty much by which Lock and which wait function they use. However, we also need an AnyIO version for CRDB; and if we added specialised support for different databases (which may have different features), we should also have matching AnyIO version of that. We have the same problem with the pool now, where we should have an AnyIOConnectionPool and an ANyIONullConnecitonPool.

So, what about using a global switch too, something like psycopg.anyio.enable() which would make so that AsyncConnection (and crdb, and pools) uses the anyio wait function and anyio lock? In this draft implementation, psycopg.anyio would be a facade module containing the likes of

try:
    import psycopg_anyio
except ImportError:
    raise ImportError(
        "anyio support is not available. Please install it using `pip install psycopg[anyio]"
    )

enable = psycopg_anyio.enable
...  # other objects if needed

Fast backwards many years, to the relation between psycopg2 and eventlet/gevent. I had the same concern of "moving targets", although the interface with these libraries proved pretty stable (but the interaction with them is only in the wait mechanism). The implementation was satisfactory for those times: providing the psycogreen external module, offering a patch_psycopg() function per backend, to explain psycopg2 how work in cooperation with it, and no expectation that in the same process users may want to use a green as well as a non-green connection.

As we are being requested to provide greenlet integration with psycopg 3 (which used to work out-of-the-box until 3.1.4, then broke, because we started using a C wait function - #527), I would think about a similar mechanism for greenelt libraries:

$ pip install psycopg[gevent]

>>> import psycopg.gevent
>>> psycopg.gevent.enable()

Differences between psycopg2 and 3:

  • pg3's repo is already a monorepo, so we can host psycopg_anyio in our infrastructure and just release on at its own release cycle, we don't need a separate package such as psycogreen and it can be still first class citizen;
  • although the similarity between anyio and the greenlet project is striking to me, actually making psycopg 3 green-compatible is pretty simple because it's enough to replace the psycopg.waiting.wait function to something monkeypatchable for these libraries to run, such as psycopg.waiting.wait_epoll.

As implementation, we could have a handful of global functions to produce a Lock, an Event, the wait() function, the getaddrinfo() function, and switching to anyio would replace these function with anyio-friendly ones.

A cleaner option would be to create an AsyncBackend class, with a global instance exposed as psycopg.backends.async_backend, which may be replaced by an instance of a subclass psycopg_anyio.AnyIOBackend; if deemed useful we could do the same with a Backend class for the different ways of doing sync things (default threading, eventlet, gevent).

What do you think?

I apologise again for the lack of interaction from my part on this feature. Many other things got my attention.

@agronholm
Copy link

Now, I see that anyio has a concept of global backend, which means that we don't expect people to use both AsyncConnection and AnyIOConnection in the same program.

Just so we're on the same page, what do you understand by the concept of "global backend"?

@dvarrazzo
Copy link
Member

dvarrazzo commented Oct 17, 2023

@agronholm

Just so we're on the same page, what do you understand by the concept of "global backend"?

What I linked, i.e. that if someone run their program into a:

from anyio import run
...
run(main, backend='trio')

then they don't expect to run asyncio resources in the same runtime. At least not via the anyio abstraction.

As a consequence, my understanding is that psycopg user using trio have no reason to use AsyncConnection() working on asyncio loop and AnyIOConnection() working on trio loop, in the same process. Hence my observation that we might not need an AnyIO* version of every Async* object we have that happens to use a lock, but we might have only one AsyncConnection() public object, for the user to use, and global configuration methods allowing to select whether to use anyio or not (then what backend should anyio use is up to its way to configure - internally we would use sniffio as @dlax has implemented here).

Is my understanding on the page you expect to be?

@agronholm
Copy link

As a consequence, my understanding is that psycopg user using trio have no reason to use AsyncConnection() working on asyncio loop and AnyIOConnection() working on trio loop, in the same process. Hence my observation that we might not need an AnyIO* version of every Async* object we have that happens to use a lock, but we might have only one AsyncConnection() public object, for the user to use, and a global configuration calls allowing to select whether to use anyio or not (then what backend should anyio use is up to its way to configure - internally we would use sniffio as @dlax has implemented here).

Is my understanding on the page you expect to be?

anyio.run() is little more than a convenience which users may or may not be using. Trio based apps might use trio-asyncio to run embedded asyncio code. As such, it is at least theoretically possible for someone to be using both backends in the same thread.

The reason your wording piqued my interest is that there is indeed something potentially coming in the AnyIO space called a "global event loop". This would allow one to provide both an async and a sync interface to a library by making the synchronous version into a shim that runs the async version in a dedicated event loop thread. This approach is demonstrated in the latest alpha of APScheduler.

@dhirschfeld
Copy link

my understanding is that psycopg user using trio have no reason to use AsyncConnection() working on asyncio loop and AnyIOConnection() working on trio loop

I don't think it's necessary (and I think we want to avoid) having duplicate implementations. My understanding is that if you implement AsyncConnection using anyio primitives then a user can import it and use it directly in either an asyncio or a trio program.

i.e. if you do have an AnyIOConnection then an asyncio user could just as well use that in their asyncio program as an AsyncConnection implemented directly using asyncio primitives.

This makes duplicate implementations redundant at best, from an end-user perspective, and in practice, doubling the api surface area and forcing the user to choose between implementations is a worse UX.

I'm not an anyio expert so perhaps @agronholm can correct me if that's not the case?

@agronholm
Copy link

Sure, ideally that is how it would work. But, I've seen some prominent downstream projects that are averse to having an unconditional dependency on AnyIO (at least httpcore comes to mind).

@dvarrazzo
Copy link
Member

My understanding is that if you implement AsyncConnection using anyio primitives then a user can import it and use it directly in either an asyncio or a trio program

@dhirschfeld read my message above: anyio has its own release cycle, on which I don't want psycopg to depend on. I don't want to drop Python 3.x support when anyio decides it's the time, I don't want to put a upper limit to the anyio version used because a non-backward-compatible change they may make makes our support difficult.

To further on it, If anyio project changes in direction we don't like, if anyio goes out of fashion because another abstraction api comes up making it easier to work with N > 2 async libraries, I don't want psycopg to depend on it.

@agronholm
Copy link

@dhirschfeld read my message above: anyio has its own release cycle, on which I don't want psycopg to depend on. I don't want to drop Python 3.x support when anyio decides it's the time, I don't want to put a upper limit to the anyio version used because a non-backward-compatible change they may make makes our support difficult.

AnyIO supports the Python releases supported by the PSF. That is to say, it won't drop support until a Python release reaches EOL. If you intend to keep supporting old Python versions longer, then this could potentially be an issue.

To further on it, If anyio project changes in direction we don't like, if anyio goes out of fashion because another abstraction api comes up making it easier to work with N > 2 async libraries, I don't want psycopg to depend on it.

I am quite skeptical that another such abstraction API would surface. It's a tremendous amount of work.

@dlax
Copy link
Contributor Author

dlax commented Oct 18, 2023

I'm totally in favour of avoiding classes duplication for each async library. (In fact, I seem to remember this was an option initially but we decided not to go that way; anyway...)

A cleaner option would be to create an AsyncBackend class, with a global instance exposed as psycopg.backends.async_backend, which may be replaced by an instance of a subclass psycopg_anyio.AnyIOBackend; if deemed useful we could do the same with a Backend class for the different ways of doing sync things (default threading, eventlet, gevent).

This option is worth a try; it's also quite similar to what's done by httpcore I think.

of course this branch now has several conflict with master, and I have no problem to help rebasing it, if we think it's the right thing to do

I think the first commits of this branch (or some of them), i.e. all but the last two, might be worth considering for integration now as they are just preparatory refactorings; they need a rebase and they indeed conflict. The last-but-one commit, the one introducing the feature, would need a complete rework if we actually go the "AsyncBackend" route, so it's not worth rebasing IMO. @dvarrazzo, if you can help rebasing (possibly only some of) the first commits, that'd indeed be appreciated. I would then be happy to take over the rest.

@dhirschfeld
Copy link

anyio has its own release cycle, on which I don't want psycopg to depend on

Unconditionally depending on anyio does to some extent couple your programs - that's the same cost/benefit calculation for taking on any dependency. In practice it would only become an issue if older versions weren't supported as otherwise you can just pin the upper bound and upgrade in your own time.

Having two separate code paths, one for an asyncio implementation and one for anyio does mean that if anyio release a backward-incompatible version 5 and immediately stop supporting version 4, you could still release psycopg with asyncio support; but there's a maintenance cost in doing so - it's more work maintaining two code paths and it exposes you to two different sets of bugs.

Maintaining separate code paths is pessimistically paying a cost for an eventuality which may never happen. It's like an insurance policy against anyio making breaking changes or stopping being supported. It might be less work to optimistically just support anyio and accept the potential cost of a rewrite if and when anyio support became untenable.

At the end of the day, I'll be happy if I can just use psycopg natively in my trio apps. As an end-user I don't care how that's implemented so I'm happy with whatever path you choose. I'm just piping up here as I care about the long-term health of the project and so don't want the maintenance burden increasing if it's not necessary/beneficial. That's entirely your decision to make though so I'll just leave it at that - I'll be happy with whatever you decide is best for your project!

@dvarrazzo
Copy link
Member

@dhirschfeld thank you for your input.

API breakage with anyio already happened in 3 -> 4; luckily it just affected the test suite. Together with the change of interface, anyio dropped support for Python 3.7. This is all very legitimate, nothing bad from their part.

We plan to drop Python 3.7 support too, but in Python 3.2, possibly in a few months time. So we would have had to do something awkward to support possibly incompatible 3 and 4, or drop support for 3.7 before planned, or set an upper boundary < 4, which is a very bad idea. Incidentally, in the test suite for the moment I did exactly that, but just because it's a test dependency, and hopefully temporary (#662 easier as Python 3.7 is now out of CI).

At the end of the day, I'll be happy if I can just use psycopg natively in my trio apps

I have also been considering that AFAICS the real feature is not supporting anyio, it is supporting trio.

  • anyio supports N backends, but at the moment N = 2 (asyncio, trio). Will there be a third? Is there anything there which might become that?
  • anyio offers remarkable features, but we don't use them.
  • we are now thinking about having our own backends system for the minimal objects we need that would be different between async framework. We would have an asyncio backend in core and we can have external backends in external modules with decoupled release cycles and dependencies. In this case, is it worth to make an anyio backend or shouldn't we just make a trio backend? I understand that, if a program depends on anyio and runs either asyncio or trio backend, psycopg would still work in that environment, in both cases.

@agronholm
Copy link

anyio supports N backends, but at the moment N = 2 (asyncio, trio). Will there be a third? Is there anything there which might become that?

AnyIO started with 3 back-ends, ended up dropping Curio as it was untenable to support and its author explicitly requested this action. One prospect has been supporting Twisted, but in all honesty, I don't think that's ever going to happen as work on sniffio support in Twisted has pretty much stalled, and there seems to be comparably little interest in Twisted support in AnyIO in the first place.

we are now thinking about having our own backends system for the minimal objects we need that would be different between async framework. We would have an asyncio backend in core and we can have external backends in external modules with decoupled release cycles and dependencies. In this case, is it worth to make an anyio backend or shouldn't we just make a trio backend? I understand that, if a program depends on anyio and runs either asyncio or trio backend, psycopg would still work in that environment, in both cases.

Whether using AnyIO makes sense depends on what you're doing. It offers a single API for task management, networking, IPC, subprocesses, async file access among other things. I personally consider the structural concurrency APIs perhaps the most important bit.

@dvarrazzo
Copy link
Member

Whether using AnyIO makes sense depends on what you're doing. It offers a single API for task management, networking, IPC, subprocesses, async file access among other things. I personally consider the structural concurrency APIs perhaps the most important bit.

The concurrency is mostly left to the users of psycopg: the core psycopg query loop is a sequence of non-blocking calls interspersed with waiting for sockets to be ready, which is where we try to play fair with whatever is running concurrently. We allow connections to be used by more than one thread, but they get effectively serialized in their request-response cycle. So, to implement all this we need a wait function and a Lock. This part of psycopg is a library, not a program that is massively concurrent on its own. I think there was an issue in the past with anyio not being able to wait for read and write at the same time on a socket, and that's a deal breaker for us (psycopg/psycopg2#1057 (comment)). I assume that if @dlax has gone so far with this MR then that's a solved problem?

There is a bit more action in the pool, which has a scheduling task and a few worker tasks waiting on a queue. In the pool we create and destroy tasks, use Queues and Conditions. I see that Trio doesn't implement a Queue object, it uses sending/receiving channels, and that anyio doesn't offer a Queue-type object either, so we should create that interface ourselves anyway, if we want to keep the similarities with the sync code. Trio doesn't have a Condition object, anyio wraps it, but it's a simple object.

Comparatively, there is way more concurrency in the test suite to simulate many different concurrency scenarios.

AFAICS the concurrency needs in our library seem simple; what we want the most is to make the library available to people who have made their architecture considerations and have decided that anyio is the tool they want to use, or that trio is.

@dlax
Copy link
Contributor Author

dlax commented Feb 28, 2024

Upon reflection, and another look at the code and discussions recently, I don't see a clear way forward for this change proposal. As it does not seem actionable by me, as the author, I am closing the PR rather than keeping it in my todo list.

@dlax dlax closed this Feb 28, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants