modal-labs / synchronicity

Synchronicity lets you interoperate with asynchronous Python APIs.
Apache License 2.0
80 stars 3 forks source link

`create_blocking` returns a function that blocks when called from a coroutine function #141

Open spencerwilson opened 6 months ago

spencerwilson commented 6 months ago

Hello 👋

Setup

Adding some assert statements to the code in the 0.6.6. README:

import asyncio

from synchronicity import Synchronizer

synchronizer = Synchronizer()

@synchronizer.create_blocking
async def f(x):
    await asyncio.sleep(1.0)
    return x**2

# Running f in a synchronous context blocks until the result is available
assert type(f(42)) == int

async def g():
    # Running f in an asynchronous context works the normal way
    ret = f(42)
    assert type(await ret) == int

asyncio.run(g())

Expected behavior

Actual behavior

Running this on synchronicity 0.6.6 in Python 3.11 one observes the following:

Traceback (most recent call last):
  File "/test.py", line 23, in <module>
    asyncio.run(g())
  File "/opt/homebrew/Cellar/python@3.11/3.11.7_1/Frameworks/Python.framework/Versions/3.11/lib/python3.11/asyncio/runners.py", line 190, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.11/3.11.7_1/Frameworks/Python.framework/Versions/3.11/lib/python3.11/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.11/3.11.7_1/Frameworks/Python.framework/Versions/3.11/lib/python3.11/asyncio/base_events.py", line 653, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "/test.py", line 20, in g
    assert type(await ret) == int
                ^^^^^^^^^
TypeError: object int can't be used in 'await' expression

More info

It seems that when calling a wrapped function from inside a coroutine function (in the example case: calling f from g), the wrapped function does not return a coroutine as suggested; instead the wrapped function blocks and returns the non-coroutine value. AFAICT it does this because it takes a code path that leads here: https://github.com/modal-labs/synchronicity/blob/daa99f36d3cd69d38f3d87b4138324145272de63/synchronicity/synchronizer.py#L307

The prose in the README also suggests that when calling a wrapped function from a coroutine function, the value returned should be an Awaitable:

When you call anything, it will detect if you're running in a synchronous or asynchronous context, and behave correspondingly.

In the synchronous case, it will simply block until the result is available (note that you can make it return a future as well, see later) In the asynchronous case, it works just like the usual business of calling asynchronous code

It's curious because the README also documents how one can pass _future=True to a wrapped function to coax it into returning an awaitable. It's a bit confusing that this is opt in after having just read both the sample code (which throws the above TypeError) and the words "In the asynchronous case, it works just like the usual business of calling asynchronous code".

spencerwilson commented 6 months ago

Another gotcha: When using _future=True the object returned is a concurrent.futures.Future, which is not an awaitable. This type is distinct from asyncio.Future, which is awaitable.

The former can in most cases be adapted into the latter using asyncio.wrap_future. The complete working code looks like:

import asyncio

from synchronicity import Synchronizer

synchronizer = Synchronizer()

@synchronizer.create_blocking
async def f(x):
    await asyncio.sleep(1.0)
    return x**2

# Running f in a synchronous context blocks until the result is available
assert type(f(42)) == int

async def g():
    # Running f in an asynchronous context works the normal way
    ret = asyncio.wrap_future(f(42, _future=True))
    assert type(await ret) == int

asyncio.run(g())