concresce 0.1.0__tar.gz
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.
concresce-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: concresce
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Transparent temporal coalescing and inline micro-batching for Python.
|
|
5
|
+
Keywords: asyncio,batching,micro-batch,performance,coalesce
|
|
6
|
+
Author: Wannes Vantorre
|
|
7
|
+
Author-email: Wannes Vantorre <vantorrewannes@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Framework :: AsyncIO
|
|
20
|
+
Requires-Python: >=3.14
|
|
21
|
+
Project-URL: Homepage, https://codeberg.org/ProductionCode/concresce
|
|
22
|
+
Project-URL: Repository, https://codeberg.org/ProductionCode/concresce.git
|
|
23
|
+
Project-URL: Issues, https://codeberg.org/ProductionCode/concresce/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# concresce 💧
|
|
27
|
+
|
|
28
|
+
**Transparent temporal coalescing and inline micro-batching.**
|
|
29
|
+
|
|
30
|
+
Standard solutions to N+1 database or network bottlenecks require spinning up external message queues, background workers, or complex task graphs. `concresce` solves this dynamically. You write the function as if it processes a single item, and the runtime transparently merges concurrent calls into a single batch in the background.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv add concresce
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## The Difference
|
|
37
|
+
|
|
38
|
+
| Concept | Standard Async Loop | Message Queues (Celery/Kafka) | `concresce` |
|
|
39
|
+
| :------------------------- | :---------------------- | :------------------------------- | :---------------------------------- |
|
|
40
|
+
| **Network Footprint** | N requests for N items. | 1 request for N items. | 1 request for N items. |
|
|
41
|
+
| **Architectural Overhead** | None. | High (Requires external broker). | None (Pure inline code). |
|
|
42
|
+
| **Return Routing** | Native variables. | Complex (Webhooks / Polling). | Native variables (Futures resolve). |
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
You need exactly three primitives: `@batch` to define the barrier constraints, `collect()` to suspend the execution and pool data, and `scatter()` to distribute the results back to the original callers.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
from concresce import batch, collect, scatter
|
|
51
|
+
|
|
52
|
+
@batch(max_window=0.05, max_size=100)
|
|
53
|
+
async def fetch_user_score(user_id):
|
|
54
|
+
# 1. Execution pauses here.
|
|
55
|
+
# Concurrent calls to this function pool their `user_id`s together.
|
|
56
|
+
batch_ids = await collect(user_id)
|
|
57
|
+
|
|
58
|
+
# 2. Only ONE execution path (the leader) resumes from this point.
|
|
59
|
+
# The others remain safely suspended in the event loop.
|
|
60
|
+
print(f"Making 1 network call for {len(batch_ids)} users...")
|
|
61
|
+
bulk_results = await db.bulk_fetch_scores(batch_ids)
|
|
62
|
+
|
|
63
|
+
# 3. The leader distributes the results back to the suspended followers.
|
|
64
|
+
# Every caller gets their specific integer back.
|
|
65
|
+
return scatter(bulk_results)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def main():
|
|
69
|
+
# Fire off 5 requests simultaneously
|
|
70
|
+
results = await asyncio.gather(
|
|
71
|
+
fetch_user_score(1),
|
|
72
|
+
fetch_user_score(2),
|
|
73
|
+
fetch_user_score(3),
|
|
74
|
+
fetch_user_score(4),
|
|
75
|
+
fetch_user_score(5)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Returns: [100, 250, 190, 300, 120]
|
|
79
|
+
print(results)
|
|
80
|
+
|
|
81
|
+
asyncio.run(main())
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Core Mechanics
|
|
85
|
+
|
|
86
|
+
- **Dual-Trigger Barrier:** The suspension breaks when either `max_size` is reached (volumetric) or `max_window` expires (temporal). High throughput processes instantly; low throughput falls back to the maximum allowed latency.
|
|
87
|
+
- **Keyed Routing:** If your bulk API returns data out-of-order or drops missing items, you can pass a dictionary to `scatter({id: result})`. It will automatically route the correct result back to the specific caller based on their original input.
|
|
88
|
+
- **Fault Propagation:** If the leader crashes during the heavy processing phase, the exception is safely replicated to all suspended followers. Nobody hangs, and the stack unwinds naturally.
|
|
89
|
+
- **Contextual Safety:** If the leader attempts to return without calling `scatter()`, `concresce` detects the structural failure and safely aborts the followers with a `RuntimeError` rather than leaving them deadlocked.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# concresce 💧
|
|
2
|
+
|
|
3
|
+
**Transparent temporal coalescing and inline micro-batching.**
|
|
4
|
+
|
|
5
|
+
Standard solutions to N+1 database or network bottlenecks require spinning up external message queues, background workers, or complex task graphs. `concresce` solves this dynamically. You write the function as if it processes a single item, and the runtime transparently merges concurrent calls into a single batch in the background.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv add concresce
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## The Difference
|
|
12
|
+
|
|
13
|
+
| Concept | Standard Async Loop | Message Queues (Celery/Kafka) | `concresce` |
|
|
14
|
+
| :------------------------- | :---------------------- | :------------------------------- | :---------------------------------- |
|
|
15
|
+
| **Network Footprint** | N requests for N items. | 1 request for N items. | 1 request for N items. |
|
|
16
|
+
| **Architectural Overhead** | None. | High (Requires external broker). | None (Pure inline code). |
|
|
17
|
+
| **Return Routing** | Native variables. | Complex (Webhooks / Polling). | Native variables (Futures resolve). |
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
You need exactly three primitives: `@batch` to define the barrier constraints, `collect()` to suspend the execution and pool data, and `scatter()` to distribute the results back to the original callers.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import asyncio
|
|
25
|
+
from concresce import batch, collect, scatter
|
|
26
|
+
|
|
27
|
+
@batch(max_window=0.05, max_size=100)
|
|
28
|
+
async def fetch_user_score(user_id):
|
|
29
|
+
# 1. Execution pauses here.
|
|
30
|
+
# Concurrent calls to this function pool their `user_id`s together.
|
|
31
|
+
batch_ids = await collect(user_id)
|
|
32
|
+
|
|
33
|
+
# 2. Only ONE execution path (the leader) resumes from this point.
|
|
34
|
+
# The others remain safely suspended in the event loop.
|
|
35
|
+
print(f"Making 1 network call for {len(batch_ids)} users...")
|
|
36
|
+
bulk_results = await db.bulk_fetch_scores(batch_ids)
|
|
37
|
+
|
|
38
|
+
# 3. The leader distributes the results back to the suspended followers.
|
|
39
|
+
# Every caller gets their specific integer back.
|
|
40
|
+
return scatter(bulk_results)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def main():
|
|
44
|
+
# Fire off 5 requests simultaneously
|
|
45
|
+
results = await asyncio.gather(
|
|
46
|
+
fetch_user_score(1),
|
|
47
|
+
fetch_user_score(2),
|
|
48
|
+
fetch_user_score(3),
|
|
49
|
+
fetch_user_score(4),
|
|
50
|
+
fetch_user_score(5)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Returns: [100, 250, 190, 300, 120]
|
|
54
|
+
print(results)
|
|
55
|
+
|
|
56
|
+
asyncio.run(main())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Core Mechanics
|
|
60
|
+
|
|
61
|
+
- **Dual-Trigger Barrier:** The suspension breaks when either `max_size` is reached (volumetric) or `max_window` expires (temporal). High throughput processes instantly; low throughput falls back to the maximum allowed latency.
|
|
62
|
+
- **Keyed Routing:** If your bulk API returns data out-of-order or drops missing items, you can pass a dictionary to `scatter({id: result})`. It will automatically route the correct result back to the specific caller based on their original input.
|
|
63
|
+
- **Fault Propagation:** If the leader crashes during the heavy processing phase, the exception is safely replicated to all suspended followers. Nobody hangs, and the stack unwinds naturally.
|
|
64
|
+
- **Contextual Safety:** If the leader attempts to return without calling `scatter()`, `concresce` detects the structural failure and safely aborts the followers with a `RuntimeError` rather than leaving them deadlocked.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "concresce"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Transparent temporal coalescing and inline micro-batching for Python."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Wannes Vantorre", email = "vantorrewannes@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
dependencies = []
|
|
12
|
+
keywords = ["asyncio", "batching", "micro-batch", "performance", "coalesce"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Framework :: AsyncIO"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://codeberg.org/ProductionCode/concresce"
|
|
29
|
+
Repository = "https://codeberg.org/ProductionCode/concresce.git"
|
|
30
|
+
Issues = "https://codeberg.org/ProductionCode/concresce/issues"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["uv_build>=0.11.3,<0.12.0"]
|
|
34
|
+
build-backend = "uv_build"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest-asyncio>=1.3.0",
|
|
39
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Suspension(Exception):
|
|
8
|
+
def __init__(self, future: asyncio.Future):
|
|
9
|
+
self.future = future
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Batch:
|
|
13
|
+
__slots__ = ("items", "futures", "event", "scattered")
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.items: List[Any] = []
|
|
17
|
+
self.futures: List[asyncio.Future] = []
|
|
18
|
+
self.event = asyncio.Event()
|
|
19
|
+
self.scattered = False
|
|
20
|
+
|
|
21
|
+
def fail(self, exception: Exception) -> None:
|
|
22
|
+
self.scattered = True
|
|
23
|
+
for f in self.futures[1:]:
|
|
24
|
+
if not f.done():
|
|
25
|
+
f.set_exception(exception)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Coordinator:
|
|
29
|
+
__slots__ = ("max_window", "max_size", "batch")
|
|
30
|
+
|
|
31
|
+
def __init__(self, max_window: float, max_size: int):
|
|
32
|
+
self.max_window = max_window
|
|
33
|
+
self.max_size = max_size
|
|
34
|
+
self.batch: Optional[Batch] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_coord: ContextVar[Coordinator] = ContextVar("concresce_coord")
|
|
38
|
+
_batch: ContextVar[Batch] = ContextVar("concresce_batch")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def collect(item: Any) -> List[Any]:
|
|
42
|
+
try:
|
|
43
|
+
coord = _coord.get()
|
|
44
|
+
except LookupError:
|
|
45
|
+
raise RuntimeError("Out of concresce context. Missing @concresce.batch?")
|
|
46
|
+
|
|
47
|
+
b = coord.batch
|
|
48
|
+
if b is None or b.event.is_set():
|
|
49
|
+
b = Batch()
|
|
50
|
+
coord.batch = b
|
|
51
|
+
|
|
52
|
+
_batch.set(b)
|
|
53
|
+
|
|
54
|
+
b.items.append(item)
|
|
55
|
+
fut = asyncio.get_running_loop().create_future()
|
|
56
|
+
b.futures.append(fut)
|
|
57
|
+
|
|
58
|
+
size = len(b.items)
|
|
59
|
+
|
|
60
|
+
if size == 1:
|
|
61
|
+
if coord.max_size > 1:
|
|
62
|
+
try:
|
|
63
|
+
await asyncio.wait_for(b.event.wait(), timeout=coord.max_window)
|
|
64
|
+
except asyncio.TimeoutError:
|
|
65
|
+
pass
|
|
66
|
+
b.event.set()
|
|
67
|
+
return b.items
|
|
68
|
+
|
|
69
|
+
if size >= coord.max_size:
|
|
70
|
+
b.event.set()
|
|
71
|
+
|
|
72
|
+
raise Suspension(fut)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def scatter(results: Union[Dict[Any, Any], Iterable[Any]]) -> Any:
|
|
76
|
+
try:
|
|
77
|
+
b = _batch.get()
|
|
78
|
+
except LookupError:
|
|
79
|
+
raise RuntimeError("Out of concresce context. Did you call collect()?")
|
|
80
|
+
|
|
81
|
+
b.scattered = True
|
|
82
|
+
|
|
83
|
+
if isinstance(results, dict):
|
|
84
|
+
for i in range(1, len(b.items)):
|
|
85
|
+
fut = b.futures[i]
|
|
86
|
+
if not fut.done():
|
|
87
|
+
try:
|
|
88
|
+
fut.set_result(results.get(b.items[i]))
|
|
89
|
+
except Exception as e:
|
|
90
|
+
fut.set_exception(e)
|
|
91
|
+
return results.get(b.items[0])
|
|
92
|
+
|
|
93
|
+
seq = list(results)
|
|
94
|
+
for i in range(1, len(b.futures)):
|
|
95
|
+
fut = b.futures[i]
|
|
96
|
+
if not fut.done():
|
|
97
|
+
try:
|
|
98
|
+
fut.set_result(seq[i])
|
|
99
|
+
except Exception as e:
|
|
100
|
+
fut.set_exception(e)
|
|
101
|
+
return seq[0]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def batch(max_window: float = 0.05, max_size: int = 100):
|
|
105
|
+
coord = Coordinator(max_window, max_size)
|
|
106
|
+
|
|
107
|
+
def decorator(func: Callable):
|
|
108
|
+
@functools.wraps(func)
|
|
109
|
+
async def wrapper(*args, **kwargs):
|
|
110
|
+
token = _coord.set(coord)
|
|
111
|
+
try:
|
|
112
|
+
result = await func(*args, **kwargs)
|
|
113
|
+
|
|
114
|
+
b = _batch.get(None)
|
|
115
|
+
if b and not b.scattered:
|
|
116
|
+
b.fail(RuntimeError("Leader completed without scattering."))
|
|
117
|
+
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
except Suspension as halt:
|
|
121
|
+
return await halt.future
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
b = _batch.get(None)
|
|
125
|
+
if b and not b.scattered:
|
|
126
|
+
b.fail(e)
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
finally:
|
|
130
|
+
_coord.reset(token)
|
|
131
|
+
|
|
132
|
+
return wrapper
|
|
133
|
+
|
|
134
|
+
return decorator
|
|
File without changes
|