ea-singleflight 0.1.0__py3-none-any.whl
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.
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ea-singleflight
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An asynchronous request coalescing utility for Python.
|
|
5
|
+
Author: Emil
|
|
6
|
+
Author-email: Emil <emilupx@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.13
|
|
14
|
+
Project-URL: Homepage, https://github.com/EmilAyupov/ea-singleflight
|
|
15
|
+
Project-URL: Repository, https://github.com/EmilAyupov/ea-singleflight
|
|
16
|
+
Project-URL: Issues, https://github.com/EmilAyupov/ea-singleflight/issues
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# SingleFlight
|
|
20
|
+
|
|
21
|
+
**SingleFlight** is a lightweight asynchronous Python utility that prevents duplicate concurrent operations.
|
|
22
|
+
|
|
23
|
+
When multiple callers request the same operation using the same key, only one operation is executed. All callers share the result of that operation.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- Deduplicates concurrent operations by key.
|
|
28
|
+
- Shares results between multiple callers.
|
|
29
|
+
- Propagates exceptions to all waiting callers.
|
|
30
|
+
- Protects shared operations from individual caller cancellation.
|
|
31
|
+
- Supports independent operations for different keys.
|
|
32
|
+
- Automatically cleans up completed operations.
|
|
33
|
+
- Built with Python's standard `asyncio` library.
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Python 3.13+
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
The package is distributed as `ea-singleflight` and imported as `singleflight`.
|
|
42
|
+
|
|
43
|
+
Once published on PyPI, install it with:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install ea-singleflight
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Until then, you can install it directly from GitHub:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install git+https://github.com/EmilAyupov/ea-singleflight.git
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
|
|
60
|
+
from singleflight import SingleFlight
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def fetch_user(user_id: int) -> dict:
|
|
64
|
+
print(f"Fetching user {user_id}...")
|
|
65
|
+
|
|
66
|
+
await asyncio.sleep(1)
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"id": user_id,
|
|
70
|
+
"name": "Alice",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def main() -> None:
|
|
75
|
+
group = SingleFlight()
|
|
76
|
+
|
|
77
|
+
results = await asyncio.gather(
|
|
78
|
+
group.do("user:123", lambda: fetch_user(123)),
|
|
79
|
+
group.do("user:123", lambda: fetch_user(123)),
|
|
80
|
+
group.do("user:123", lambda: fetch_user(123)),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
print(results)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
asyncio.run(main())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Although three callers request the same operation, `fetch_user()` is executed only once.
|
|
91
|
+
|
|
92
|
+
All three callers receive the same result.
|
|
93
|
+
|
|
94
|
+
## How It Works
|
|
95
|
+
|
|
96
|
+
SingleFlight maintains a collection of in-flight operations indexed by string keys.
|
|
97
|
+
|
|
98
|
+
When `do()` is called:
|
|
99
|
+
|
|
100
|
+
1. If no operation exists for the given key, a new asynchronous task is created.
|
|
101
|
+
2. If an operation already exists, the caller joins the existing task.
|
|
102
|
+
3. All callers await the same task.
|
|
103
|
+
4. Once the operation completes and all waiting callers finish, the corresponding entry is removed.
|
|
104
|
+
|
|
105
|
+
Operations with different keys run independently.
|
|
106
|
+
|
|
107
|
+
## Cancellation Behavior
|
|
108
|
+
|
|
109
|
+
SingleFlight uses `asyncio.shield()` to protect shared tasks from cancellation initiated by individual callers.
|
|
110
|
+
|
|
111
|
+
If one caller is cancelled:
|
|
112
|
+
|
|
113
|
+
- That caller receives `asyncio.CancelledError`.
|
|
114
|
+
- The shared operation continues running.
|
|
115
|
+
- Other callers can still receive its result.
|
|
116
|
+
|
|
117
|
+
If all callers are cancelled, the shared operation continues running and can be joined by a new caller using the same key.
|
|
118
|
+
|
|
119
|
+
## Exception Handling
|
|
120
|
+
|
|
121
|
+
If the shared operation raises an exception, all waiting callers receive that exception.
|
|
122
|
+
|
|
123
|
+
Once the operation has completed and its callers have finished, the corresponding entry is removed.
|
|
124
|
+
|
|
125
|
+
A subsequent call using the same key starts a new operation.
|
|
126
|
+
|
|
127
|
+
## API
|
|
128
|
+
|
|
129
|
+
### `SingleFlight()`
|
|
130
|
+
|
|
131
|
+
Creates a new SingleFlight instance.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
group = SingleFlight()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### `await group.do(key, fn)`
|
|
138
|
+
|
|
139
|
+
Executes or joins an asynchronous operation associated with the given key.
|
|
140
|
+
|
|
141
|
+
**Parameters:**
|
|
142
|
+
|
|
143
|
+
| Parameter | Type | Description |
|
|
144
|
+
| --------- | ---------- | -------------------------------------------- |
|
|
145
|
+
| `key` | `str` | Identifier of the operation |
|
|
146
|
+
| `fn` | `Callable` | Zero-argument function returning a coroutine |
|
|
147
|
+
|
|
148
|
+
**Returns:**
|
|
149
|
+
|
|
150
|
+
The result of the shared asynchronous operation.
|
|
151
|
+
|
|
152
|
+
**Raises:**
|
|
153
|
+
|
|
154
|
+
- `TypeError` if `fn` does not return a coroutine.
|
|
155
|
+
- Any exception raised by the shared operation.
|
|
156
|
+
- `asyncio.CancelledError` if the individual caller is cancelled or the shared task is cancelled.
|
|
157
|
+
|
|
158
|
+
## Limitations
|
|
159
|
+
|
|
160
|
+
- Keys must be strings.
|
|
161
|
+
- Only asynchronous functions returning coroutines are supported.
|
|
162
|
+
- Callers using the same key must expect compatible results.
|
|
163
|
+
- The first caller's function is used; subsequent functions for the same key are not executed.
|
|
164
|
+
- A SingleFlight instance is intended for use within a single asyncio event loop.
|
|
165
|
+
- The implementation is not thread-safe.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
Clone the repository:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
git clone https://github.com/EmilAyupov/ea-singleflight.git
|
|
173
|
+
cd ea-singleflight
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Install dependencies:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
uv sync --all-groups
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Run checks:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
./scripts/check.sh
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The project uses:
|
|
189
|
+
|
|
190
|
+
- [Python](https://www.python.org/)
|
|
191
|
+
- [asyncio](https://docs.python.org/3/library/asyncio.html)
|
|
192
|
+
- [pytest](https://pytest.org/)
|
|
193
|
+
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/)
|
|
194
|
+
- [Ruff](https://docs.astral.sh/ruff/)
|
|
195
|
+
- [Pyrefly](https://pyrefly.org/)
|
|
196
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
singleflight/__init__.py,sha256=3tG3dW1Cy6fHIcIdhhUHA65xUQMdNIOeweV5YFlx3_k,59
|
|
2
|
+
singleflight/main.py,sha256=mlW1lZqWkso97gZ493A0D-XrX55o1zyfbnYbFNe7FOw,1601
|
|
3
|
+
singleflight/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
ea_singleflight-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
5
|
+
ea_singleflight-0.1.0.dist-info/METADATA,sha256=3zFs6rfcuh1sfFDOpOExrBWpFZdTjotrovM3xNSKsJ8,5424
|
|
6
|
+
ea_singleflight-0.1.0.dist-info/RECORD,,
|
singleflight/__init__.py
ADDED
singleflight/main.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Callable, Coroutine
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Flight[T]:
|
|
11
|
+
task: asyncio.Task[T]
|
|
12
|
+
waiters: int = 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SingleFlight:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._flights: dict[str, Flight[Any]] = {}
|
|
18
|
+
self._lock = asyncio.Lock()
|
|
19
|
+
|
|
20
|
+
async def do(
|
|
21
|
+
self,
|
|
22
|
+
key: str,
|
|
23
|
+
fn: Callable[[], Coroutine[Any, Any, T]],
|
|
24
|
+
) -> T:
|
|
25
|
+
async with self._lock:
|
|
26
|
+
flight = self._flights.get(key)
|
|
27
|
+
|
|
28
|
+
if flight is None:
|
|
29
|
+
coro = fn()
|
|
30
|
+
|
|
31
|
+
if not asyncio.iscoroutine(coro):
|
|
32
|
+
raise TypeError("fn must return a coroutine")
|
|
33
|
+
|
|
34
|
+
task = asyncio.create_task(coro)
|
|
35
|
+
flight = Flight(task=task)
|
|
36
|
+
self._flights[key] = flight
|
|
37
|
+
|
|
38
|
+
task.add_done_callback(lambda _: self._on_task_done(key, flight))
|
|
39
|
+
|
|
40
|
+
flight.waiters += 1
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
result = await asyncio.shield(flight.task)
|
|
44
|
+
return cast(T, result)
|
|
45
|
+
finally:
|
|
46
|
+
flight.waiters -= 1
|
|
47
|
+
self._cleanup(key, flight)
|
|
48
|
+
|
|
49
|
+
def _cleanup(self, key: str, flight: Flight[Any]) -> None:
|
|
50
|
+
if (
|
|
51
|
+
self._flights.get(key) is flight
|
|
52
|
+
and flight.task.done()
|
|
53
|
+
and flight.waiters == 0
|
|
54
|
+
):
|
|
55
|
+
del self._flights[key]
|
|
56
|
+
|
|
57
|
+
def _on_task_done(self, key: str, flight: Flight[Any]) -> None:
|
|
58
|
+
if not flight.task.cancelled():
|
|
59
|
+
flight.task.exception()
|
|
60
|
+
|
|
61
|
+
self._cleanup(key, flight)
|
singleflight/py.typed
ADDED
|
File without changes
|