pyattacker 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.
- pyattacker/__init__.py +198 -0
- pyattacker/__main__.py +12 -0
- pyattacker/algorithm.py +355 -0
- pyattacker/artifact.py +255 -0
- pyattacker/backends.py +228 -0
- pyattacker/cli.py +682 -0
- pyattacker/declarative.py +327 -0
- pyattacker/errors.py +223 -0
- pyattacker/export.py +291 -0
- pyattacker/merge.py +177 -0
- pyattacker/monitor.py +108 -0
- pyattacker/pipeline.py +241 -0
- pyattacker/plugins.py +244 -0
- pyattacker/resource.py +1018 -0
- pyattacker/runner.py +1406 -0
- pyattacker/scheduler.py +135 -0
- pyattacker/server.py +297 -0
- pyattacker/shard.py +107 -0
- pyattacker/store/__init__.py +32 -0
- pyattacker/store/base.py +351 -0
- pyattacker/store/memory.py +368 -0
- pyattacker/store/sqlite.py +734 -0
- pyattacker/store/writebehind.py +262 -0
- pyattacker/task.py +508 -0
- pyattacker/tasks/__init__.py +380 -0
- pyattacker-0.1.0.dist-info/METADATA +347 -0
- pyattacker-0.1.0.dist-info/RECORD +30 -0
- pyattacker-0.1.0.dist-info/WHEEL +4 -0
- pyattacker-0.1.0.dist-info/entry_points.txt +2 -0
- pyattacker-0.1.0.dist-info/licenses/LICENSE +21 -0
pyattacker/__init__.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""pyattacker —— an artifact-centric async task orchestration framework.
|
|
2
|
+
|
|
3
|
+
Core model (five concepts, in data-flow order)::
|
|
4
|
+
|
|
5
|
+
artifact a task's persisted state (content-addressed, persisted as soon as it is produced → task-level checkpoint)
|
|
6
|
+
task a unary function (artifact) -> artifact, the smallest unit of scheduling
|
|
7
|
+
pipeline several tasks chained linearly; the unit of **completion** and **resume**, semantically independent of each other
|
|
8
|
+
resource a concurrency-safe publishable/subscribable resource (such as a provider endpoint)
|
|
9
|
+
algorithm the strategy for "how to take a resource from the pool" (wait/backoff/switch pool/pick the least busy)
|
|
10
|
+
|
|
11
|
+
Minimal usage::
|
|
12
|
+
|
|
13
|
+
from pyattacker import Runner, pipeline, task, Pool, Resource
|
|
14
|
+
|
|
15
|
+
@task("fetch")
|
|
16
|
+
def fetch(seed, ctx): ...
|
|
17
|
+
|
|
18
|
+
@task("ask", resource="apis", retry={"max_attempts": 3})
|
|
19
|
+
async def ask(row, ctx):
|
|
20
|
+
async with ctx.acquire(model="gpt-4o") as lease: # returned on exit, guaranteed even on exception
|
|
21
|
+
return await lease.client.chat(row["q"])
|
|
22
|
+
|
|
23
|
+
pool = Pool("apis", [Resource.create("llm", capacity=4, options={...})], algorithm="backoff")
|
|
24
|
+
with Runner(store="runs.db", pools=[pool], concurrency=16) as runner:
|
|
25
|
+
report = runner.run(pipeline("qa", fetch | ask).map(dataset))
|
|
26
|
+
print(report.summary())
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from .algorithm import (
|
|
32
|
+
Backoff,
|
|
33
|
+
Failover,
|
|
34
|
+
Immediate,
|
|
35
|
+
LeastBusy,
|
|
36
|
+
QuotaAware,
|
|
37
|
+
Sticky,
|
|
38
|
+
Wait,
|
|
39
|
+
resolve_algorithm,
|
|
40
|
+
)
|
|
41
|
+
from .artifact import (
|
|
42
|
+
Artifact,
|
|
43
|
+
BytesCodec,
|
|
44
|
+
Codec,
|
|
45
|
+
CodecRegistry,
|
|
46
|
+
Encoded,
|
|
47
|
+
JsonCodec,
|
|
48
|
+
canonical_json,
|
|
49
|
+
digest_of,
|
|
50
|
+
)
|
|
51
|
+
from .backends import ArtifactBackend, FileBackend, InlineBackend, NullBackend, resolve_backend
|
|
52
|
+
from .declarative import load_spec
|
|
53
|
+
from .errors import (
|
|
54
|
+
AcquireTimeout,
|
|
55
|
+
ArtifactCodecError,
|
|
56
|
+
BudgetExceeded,
|
|
57
|
+
ConfigError,
|
|
58
|
+
FatalError,
|
|
59
|
+
LeaseLeakError,
|
|
60
|
+
PipelineBuildError,
|
|
61
|
+
PluginError,
|
|
62
|
+
PoolNotFound,
|
|
63
|
+
PyAttackerError,
|
|
64
|
+
ResourceUnavailable,
|
|
65
|
+
RetryableError,
|
|
66
|
+
RunInterrupted,
|
|
67
|
+
StoreUnavailable,
|
|
68
|
+
error_class_of,
|
|
69
|
+
)
|
|
70
|
+
from .export import FORMATS, ROW_KINDS, export_store, export_stores, iter_rows
|
|
71
|
+
from .merge import MergedReport, merge_reports
|
|
72
|
+
from .pipeline import Chain, PipelineSpec, PipelineTemplate, compute_spec_digest, pipeline, with_retry
|
|
73
|
+
from .plugins import PLUGINS, PluginRegistry, list_plugins
|
|
74
|
+
from .resource import Bus, Lease, Pool, Resource, ResourceEvent, ResourceState
|
|
75
|
+
from .runner import RunConfig, Runner, RunReport
|
|
76
|
+
from .server import StatsServer
|
|
77
|
+
from .shard import in_shard, parse_shard, shard_index, shard_specs, shard_store_path
|
|
78
|
+
from .store import AttemptRecord, EventRecord, MemoryStore, PipelineRecord, SqliteStore, open_store
|
|
79
|
+
from .task import Retrying, TaskContext, TaskSpec, build_task_spec, task
|
|
80
|
+
from .tasks import (
|
|
81
|
+
boom,
|
|
82
|
+
delay,
|
|
83
|
+
echo,
|
|
84
|
+
fanout,
|
|
85
|
+
flaky,
|
|
86
|
+
jsonl_source,
|
|
87
|
+
leaky,
|
|
88
|
+
seed_factory,
|
|
89
|
+
shell_run,
|
|
90
|
+
simulate_llm,
|
|
91
|
+
write_jsonl,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
__version__ = "0.1.0"
|
|
95
|
+
|
|
96
|
+
__all__ = [
|
|
97
|
+
"__version__",
|
|
98
|
+
# core objects
|
|
99
|
+
"Runner",
|
|
100
|
+
"RunConfig",
|
|
101
|
+
"RunReport",
|
|
102
|
+
"pipeline",
|
|
103
|
+
"task",
|
|
104
|
+
"Pool",
|
|
105
|
+
"Resource",
|
|
106
|
+
"Lease",
|
|
107
|
+
"Bus",
|
|
108
|
+
"ResourceEvent",
|
|
109
|
+
"ResourceState",
|
|
110
|
+
"Retrying",
|
|
111
|
+
"TaskSpec",
|
|
112
|
+
"TaskContext",
|
|
113
|
+
"build_task_spec",
|
|
114
|
+
"Chain",
|
|
115
|
+
"PipelineSpec",
|
|
116
|
+
"PipelineTemplate",
|
|
117
|
+
"compute_spec_digest",
|
|
118
|
+
"with_retry",
|
|
119
|
+
"Artifact",
|
|
120
|
+
"Codec",
|
|
121
|
+
"CodecRegistry",
|
|
122
|
+
"JsonCodec",
|
|
123
|
+
"BytesCodec",
|
|
124
|
+
"Encoded",
|
|
125
|
+
"canonical_json",
|
|
126
|
+
"digest_of",
|
|
127
|
+
# algorithms
|
|
128
|
+
"resolve_algorithm",
|
|
129
|
+
"Immediate",
|
|
130
|
+
"Wait",
|
|
131
|
+
"Backoff",
|
|
132
|
+
"LeastBusy",
|
|
133
|
+
"Failover",
|
|
134
|
+
"Sticky",
|
|
135
|
+
"QuotaAware",
|
|
136
|
+
# store
|
|
137
|
+
"open_store",
|
|
138
|
+
"MemoryStore",
|
|
139
|
+
"SqliteStore",
|
|
140
|
+
"AttemptRecord",
|
|
141
|
+
"EventRecord",
|
|
142
|
+
"PipelineRecord",
|
|
143
|
+
# declarative
|
|
144
|
+
"load_spec",
|
|
145
|
+
# sharding, merging, export
|
|
146
|
+
"shard_index",
|
|
147
|
+
"in_shard",
|
|
148
|
+
"shard_specs",
|
|
149
|
+
"shard_store_path",
|
|
150
|
+
"parse_shard",
|
|
151
|
+
"merge_reports",
|
|
152
|
+
"MergedReport",
|
|
153
|
+
"iter_rows",
|
|
154
|
+
"export_store",
|
|
155
|
+
"export_stores",
|
|
156
|
+
"ROW_KINDS",
|
|
157
|
+
"FORMATS",
|
|
158
|
+
# plugins
|
|
159
|
+
"PLUGINS",
|
|
160
|
+
"PluginRegistry",
|
|
161
|
+
"list_plugins",
|
|
162
|
+
# artifact backends
|
|
163
|
+
"ArtifactBackend",
|
|
164
|
+
"InlineBackend",
|
|
165
|
+
"FileBackend",
|
|
166
|
+
"NullBackend",
|
|
167
|
+
"resolve_backend",
|
|
168
|
+
# monitoring endpoint
|
|
169
|
+
"StatsServer",
|
|
170
|
+
# built-in tasks
|
|
171
|
+
"echo",
|
|
172
|
+
"fanout",
|
|
173
|
+
"flaky",
|
|
174
|
+
"delay",
|
|
175
|
+
"boom",
|
|
176
|
+
"leaky",
|
|
177
|
+
"simulate_llm",
|
|
178
|
+
"shell_run",
|
|
179
|
+
"write_jsonl",
|
|
180
|
+
"jsonl_source",
|
|
181
|
+
"seed_factory",
|
|
182
|
+
# exceptions
|
|
183
|
+
"PyAttackerError",
|
|
184
|
+
"ConfigError",
|
|
185
|
+
"PipelineBuildError",
|
|
186
|
+
"PluginError",
|
|
187
|
+
"ArtifactCodecError",
|
|
188
|
+
"ResourceUnavailable",
|
|
189
|
+
"AcquireTimeout",
|
|
190
|
+
"PoolNotFound",
|
|
191
|
+
"LeaseLeakError",
|
|
192
|
+
"RetryableError",
|
|
193
|
+
"FatalError",
|
|
194
|
+
"BudgetExceeded",
|
|
195
|
+
"RunInterrupted",
|
|
196
|
+
"StoreUnavailable",
|
|
197
|
+
"error_class_of",
|
|
198
|
+
]
|
pyattacker/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""``python -m pyattacker`` — the same entry point as the console script.
|
|
2
|
+
|
|
3
|
+
Used by ``pyattacker run --shards N``, which spawns child processes this way so it works even
|
|
4
|
+
when the package is imported from a source checkout rather than an installed script.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .cli import main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
raise SystemExit(main())
|
pyattacker/algorithm.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Algorithm —— the strategy for "how to get a resource out of a pool".
|
|
2
|
+
|
|
3
|
+
How it differs from retry (two orthogonal axes; do not conflate them):
|
|
4
|
+
|
|
5
|
+
* **algorithm**: before doing the work, how to wait for/select an available
|
|
6
|
+
resource (wait, back off, switch pools, pick the least-busy one).
|
|
7
|
+
* **retry**: after the work fails, whether/when to run it again (exception-driven).
|
|
8
|
+
|
|
9
|
+
The single exit for every algorithm is :meth:`Pool.acquire`; they only compose
|
|
10
|
+
three primitives: ``pool.try_acquire()`` (synchronous attempt),
|
|
11
|
+
``pool.wait_slot()`` (wait for a broadcast), and ``clock.sleep()`` (backoff).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import random
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
from .errors import AcquireTimeout, PoolNotFound, ResourceUnavailable
|
|
22
|
+
from .resource import Lease, Pool
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AcquireAlgorithm",
|
|
26
|
+
"Immediate",
|
|
27
|
+
"Wait",
|
|
28
|
+
"Backoff",
|
|
29
|
+
"LeastBusy",
|
|
30
|
+
"Failover",
|
|
31
|
+
"Sticky",
|
|
32
|
+
"QuotaAware",
|
|
33
|
+
"resolve_algorithm",
|
|
34
|
+
"ALGORITHMS",
|
|
35
|
+
"backoff_delay",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def backoff_delay(attempt: int, rng: random.Random, *, base: float, factor: float, cap: float, jitter: str) -> float:
|
|
40
|
+
"""Exponential backoff + jitter, shared by :class:`Backoff` (acquire) and ``Retrying.delay_for`` (retry).
|
|
41
|
+
|
|
42
|
+
Both call sites want the same "raw exponential, then jitter" shape even though they back off
|
|
43
|
+
for different reasons (waiting for pool capacity vs. waiting to retry a failed attempt); a
|
|
44
|
+
single implementation means changing the formula (e.g. to decorrelated jitter) only has to
|
|
45
|
+
happen once, instead of two copies quietly drifting apart from each other.
|
|
46
|
+
"""
|
|
47
|
+
raw = min(cap, base * factor ** max(0, attempt - 1))
|
|
48
|
+
if jitter == "full":
|
|
49
|
+
return rng.uniform(0, raw)
|
|
50
|
+
if jitter == "equal":
|
|
51
|
+
return raw / 2 + rng.uniform(0, raw / 2)
|
|
52
|
+
return raw
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@runtime_checkable
|
|
56
|
+
class AcquireAlgorithm(Protocol):
|
|
57
|
+
name: str
|
|
58
|
+
|
|
59
|
+
async def acquire(
|
|
60
|
+
self,
|
|
61
|
+
pool: Pool,
|
|
62
|
+
*,
|
|
63
|
+
ctx: Any = None,
|
|
64
|
+
where: Callable[[Any], bool] | None = None,
|
|
65
|
+
timeout: float | None = None,
|
|
66
|
+
selector: dict[str, Any] | None = None,
|
|
67
|
+
) -> Lease: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _describe(pool: Pool, selector: dict[str, Any] | None, where: Any) -> str:
|
|
71
|
+
stats = pool.stats(**{k: v for k, v in (selector or {}).items() if k in ("kind", "id")})
|
|
72
|
+
return (
|
|
73
|
+
f"pool={pool.name!r} selector={selector or {}} where={'yes' if where else 'no'} "
|
|
74
|
+
f"resources={stats.total} ready={stats.ready} degraded={stats.degraded} "
|
|
75
|
+
f"dead={stats.dead} active={stats.active}/{stats.capacity} waiting={stats.waiting}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Immediate:
|
|
81
|
+
"""Fail immediately when nothing is available (fast-fail on capacity shortage)."""
|
|
82
|
+
|
|
83
|
+
name: str = "immediate"
|
|
84
|
+
|
|
85
|
+
async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
|
|
86
|
+
lease = pool.try_acquire(where=where, ctx=ctx, **(selector or {}))
|
|
87
|
+
if lease is None:
|
|
88
|
+
raise ResourceUnavailable(f"no resource available: {_describe(pool, selector, where)}")
|
|
89
|
+
return lease
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Wait:
|
|
94
|
+
"""FIFO wait (the default algorithm), optionally with a timeout."""
|
|
95
|
+
|
|
96
|
+
name: str = "wait"
|
|
97
|
+
timeout: float | None = None
|
|
98
|
+
|
|
99
|
+
async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
|
|
100
|
+
deadline_timeout = timeout if timeout is not None else self.timeout
|
|
101
|
+
started = pool.clock.now()
|
|
102
|
+
while True:
|
|
103
|
+
lease = pool.try_acquire(where=where, ctx=ctx, **(selector or {}))
|
|
104
|
+
if lease is not None:
|
|
105
|
+
# Backfill the real waiting time: try_acquire only ever sees 0, and time spent
|
|
106
|
+
# blocked here is exactly what pool stats should report.
|
|
107
|
+
pool.note_wait(lease, (pool.clock.now() - started) * 1000.0, selector=selector)
|
|
108
|
+
return lease
|
|
109
|
+
remaining = None
|
|
110
|
+
if deadline_timeout is not None:
|
|
111
|
+
remaining = deadline_timeout - (pool.clock.now() - started)
|
|
112
|
+
if remaining <= 0:
|
|
113
|
+
raise AcquireTimeout(f"timed out waiting for a resource ({deadline_timeout}s): {_describe(pool, selector, where)}")
|
|
114
|
+
got = await pool.wait_slot(remaining, ctx=ctx, where=where, selector=selector)
|
|
115
|
+
if not got and deadline_timeout is not None:
|
|
116
|
+
raise AcquireTimeout(f"timed out waiting for a resource ({deadline_timeout}s): {_describe(pool, selector, where)}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class Backoff:
|
|
121
|
+
"""Backoff-style acquire: when the pool is saturated / a resource is in circuit-break cooldown, retry after exponential backoff.
|
|
122
|
+
|
|
123
|
+
This is the default remedy for the "API is saturated" scenario.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
name: str = "backoff"
|
|
127
|
+
base: float = 0.2
|
|
128
|
+
factor: float = 2.0
|
|
129
|
+
cap: float = 10.0
|
|
130
|
+
jitter: str = "full"
|
|
131
|
+
max_wait: float | None = None
|
|
132
|
+
|
|
133
|
+
async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
|
|
134
|
+
limit = timeout if timeout is not None else self.max_wait
|
|
135
|
+
started = pool.clock.now()
|
|
136
|
+
rng = getattr(ctx, "rng", None) or random.Random()
|
|
137
|
+
attempt = 0
|
|
138
|
+
while True:
|
|
139
|
+
lease = pool.try_acquire(where=where, ctx=ctx, **(selector or {}))
|
|
140
|
+
if lease is not None:
|
|
141
|
+
pool.note_wait(lease, (pool.clock.now() - started) * 1000.0, selector=selector)
|
|
142
|
+
return lease
|
|
143
|
+
attempt += 1
|
|
144
|
+
delay = backoff_delay(attempt, rng, base=self.base, factor=self.factor, cap=self.cap, jitter=self.jitter)
|
|
145
|
+
elapsed = pool.clock.now() - started
|
|
146
|
+
if limit is not None and elapsed + delay >= limit:
|
|
147
|
+
raise AcquireTimeout(f"timed out waiting for a resource with backoff ({limit}s): {_describe(pool, selector, where)}")
|
|
148
|
+
if ctx is not None:
|
|
149
|
+
ctx.emit(
|
|
150
|
+
"acquire.backoff",
|
|
151
|
+
pool=pool.name,
|
|
152
|
+
attempt=attempt,
|
|
153
|
+
delay_s=round(delay, 4),
|
|
154
|
+
selector=dict(selector or {}),
|
|
155
|
+
)
|
|
156
|
+
await pool.clock.sleep(delay)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class LeastBusy:
|
|
161
|
+
"""Pick the resource with the lowest load ratio; if none is available, fall back to ``fallback`` (Wait by default)."""
|
|
162
|
+
|
|
163
|
+
name: str = "least_busy"
|
|
164
|
+
fallback: AcquireAlgorithm | None = None
|
|
165
|
+
|
|
166
|
+
async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
|
|
167
|
+
def pick(resource: Any) -> bool:
|
|
168
|
+
return where is None or bool(where(resource))
|
|
169
|
+
|
|
170
|
+
lease = pool.try_acquire(where=pick, ctx=ctx, **(selector or {}))
|
|
171
|
+
if lease is not None:
|
|
172
|
+
return lease
|
|
173
|
+
fallback = self.fallback or Wait()
|
|
174
|
+
return await fallback.acquire(pool, ctx=ctx, where=where, timeout=timeout, selector=selector)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class Failover:
|
|
179
|
+
"""Fail over across multiple pools in order; when all are unavailable, defer to ``fallback``.
|
|
180
|
+
|
|
181
|
+
``fallback`` (``Wait()`` by default) only ever runs against ``pools[0]`` — once every pool has
|
|
182
|
+
been tried immediately and none had capacity, this waits on the first (primary) pool rather
|
|
183
|
+
than looping ``fallback`` across the whole list. That is deliberate: the list is meant to be
|
|
184
|
+
read as "try these in order, then park on the one you actually want", not as "wait on
|
|
185
|
+
whichever of these frees up first" (that is what :class:`LeastBusy` or plain ``Wait`` on a
|
|
186
|
+
single pool covering all resources are for).
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
name: str = "failover"
|
|
190
|
+
pools: Sequence[str] = ()
|
|
191
|
+
fallback: AcquireAlgorithm | None = None
|
|
192
|
+
|
|
193
|
+
async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
|
|
194
|
+
names = list(self.pools) or [pool.name]
|
|
195
|
+
available = getattr(ctx, "pools", {}) or {}
|
|
196
|
+
reasons: dict[str, str] = {}
|
|
197
|
+
last_error: Exception | None = None
|
|
198
|
+
for name in names:
|
|
199
|
+
target = available.get(name)
|
|
200
|
+
if target is None:
|
|
201
|
+
reasons[name] = "unknown resource pool"
|
|
202
|
+
last_error = last_error or PoolNotFound(f"unknown resource pool: {name}")
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
return await Immediate().acquire(
|
|
206
|
+
target, ctx=ctx, where=where, timeout=timeout, selector=selector
|
|
207
|
+
)
|
|
208
|
+
except ResourceUnavailable as exc:
|
|
209
|
+
reasons[name] = str(exc).split(":")[0]
|
|
210
|
+
last_error = exc
|
|
211
|
+
fallback = self.fallback or Wait()
|
|
212
|
+
try:
|
|
213
|
+
target = available.get(names[0], pool)
|
|
214
|
+
return await fallback.acquire(
|
|
215
|
+
target, ctx=ctx, where=where, timeout=timeout, selector=selector
|
|
216
|
+
)
|
|
217
|
+
except (ResourceUnavailable, AcquireTimeout) as exc:
|
|
218
|
+
# Report every pool's reason: hiding them behind the last error makes failover
|
|
219
|
+
# misconfiguration (a typo'd pool name) look like plain capacity shortage.
|
|
220
|
+
reasons[names[0]] = f"{reasons.get(names[0], 'no resource available')} (fallback: {exc})"
|
|
221
|
+
detail = "; ".join(f"{name}: {reason}" for name, reason in reasons.items())
|
|
222
|
+
error = ResourceUnavailable(f"no pool available: {names} -> {detail}")
|
|
223
|
+
raise error from (last_error or exc)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _affinity(ctx: Any, pool_name: str) -> str | None:
|
|
227
|
+
if ctx is None:
|
|
228
|
+
return None
|
|
229
|
+
return (ctx.meta.get("sticky") or {}).get(pool_name)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _remember_affinity(ctx: Any, pool_name: str, resource_id: str) -> None:
|
|
233
|
+
if ctx is None:
|
|
234
|
+
return
|
|
235
|
+
ctx.meta.setdefault("sticky", {})[pool_name] = resource_id
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class Sticky:
|
|
240
|
+
"""Prefer the resource this attempt already used.
|
|
241
|
+
|
|
242
|
+
Useful when the provider gives you something for free by staying on one endpoint:
|
|
243
|
+
prompt/prefix caches, warm connections, sticky sessions. The affinity lives on the task
|
|
244
|
+
context, which is created per **attempt**, so it holds for every acquire inside one attempt
|
|
245
|
+
(including a loop that leases and returns several times) and never leaks between pipelines.
|
|
246
|
+
A retry or the next task starts a fresh context and may land elsewhere.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
name: str = "sticky"
|
|
250
|
+
fallback: AcquireAlgorithm | None = None
|
|
251
|
+
|
|
252
|
+
async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
|
|
253
|
+
preferred = _affinity(ctx, pool.name)
|
|
254
|
+
if preferred is not None:
|
|
255
|
+
lease = pool.select(
|
|
256
|
+
None, where=lambda r, _pref=preferred: r.id == _pref, ctx=ctx, **(selector or {})
|
|
257
|
+
)
|
|
258
|
+
if lease is not None:
|
|
259
|
+
return lease
|
|
260
|
+
inner = self.fallback or Wait()
|
|
261
|
+
lease = await inner.acquire(pool, ctx=ctx, where=where, timeout=timeout, selector=selector)
|
|
262
|
+
_remember_affinity(ctx, pool.name, lease.resource.id)
|
|
263
|
+
return lease
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass
|
|
267
|
+
class QuotaAware:
|
|
268
|
+
"""Pick the resource with the most remaining quota.
|
|
269
|
+
|
|
270
|
+
Quota is declared on the resource (``options={"quota": {"tokens": 1_000_000}}``) and
|
|
271
|
+
consumed through ``lease.report(usage={"tokens": n})``. This is a *preference*, not a hard
|
|
272
|
+
limit: when every candidate is exhausted the best of them is still used, because refusing
|
|
273
|
+
to work is worse than overspending. For a hard stop, have the task raise once its own
|
|
274
|
+
budget is gone.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
name: str = "quota_aware"
|
|
278
|
+
metric: str = "tokens"
|
|
279
|
+
reserve: float = 0.05
|
|
280
|
+
fallback: AcquireAlgorithm | None = None
|
|
281
|
+
|
|
282
|
+
def score(self, resource: Any, stats: Any) -> tuple[float, float]:
|
|
283
|
+
"""Rank by *remaining ratio*, then by *absolute remaining* as the tie-break.
|
|
284
|
+
|
|
285
|
+
Ratio first so a nearly-exhausted resource is avoided even when its quota is huge;
|
|
286
|
+
absolute second so that between two equally-fresh resources the roomier one wins.
|
|
287
|
+
Unknown quota ranks below anything metered, and anything inside the reserve ranks
|
|
288
|
+
below everything else while staying rankable.
|
|
289
|
+
"""
|
|
290
|
+
quota = (resource.options.get("quota") or {}).get(self.metric)
|
|
291
|
+
if not quota:
|
|
292
|
+
return (-100.0, 0.0) # unknown quota ranks below anything with a known one
|
|
293
|
+
quota_f = float(quota)
|
|
294
|
+
remaining = quota_f - float(stats.usage.get(self.metric, 0.0))
|
|
295
|
+
ratio = remaining / quota_f
|
|
296
|
+
if ratio <= self.reserve:
|
|
297
|
+
ratio -= 10.0 # inside the reserve: usable, but only as a last resort
|
|
298
|
+
return (ratio, remaining)
|
|
299
|
+
|
|
300
|
+
async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
|
|
301
|
+
lease = pool.select(self.score, where=where, ctx=ctx, **(selector or {}))
|
|
302
|
+
if lease is not None:
|
|
303
|
+
return lease
|
|
304
|
+
inner = self.fallback or Wait()
|
|
305
|
+
return await inner.acquire(pool, ctx=ctx, where=where, timeout=timeout, selector=selector)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
ALGORITHMS: dict[str, type] = {
|
|
309
|
+
"immediate": Immediate,
|
|
310
|
+
"wait": Wait,
|
|
311
|
+
"backoff": Backoff,
|
|
312
|
+
"least_busy": LeastBusy,
|
|
313
|
+
"failover": Failover,
|
|
314
|
+
"sticky": Sticky,
|
|
315
|
+
"quota_aware": QuotaAware,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def resolve_algorithm(spec: Any) -> AcquireAlgorithm:
|
|
320
|
+
"""Normalize ``"backoff"`` / ``{"name": "backoff", "base": 1}`` / an instance into an algorithm object."""
|
|
321
|
+
if spec is None:
|
|
322
|
+
return Wait()
|
|
323
|
+
if isinstance(spec, str):
|
|
324
|
+
if spec in ALGORITHMS:
|
|
325
|
+
return ALGORITHMS[spec]()
|
|
326
|
+
from .plugins import PLUGINS
|
|
327
|
+
|
|
328
|
+
plugin = PLUGINS.algorithm(spec)
|
|
329
|
+
if plugin is not None:
|
|
330
|
+
return plugin
|
|
331
|
+
raise PoolNotFound(
|
|
332
|
+
f"unknown acquire algorithm: {spec!r}, choices: {sorted(ALGORITHMS)} "
|
|
333
|
+
f"+ installed plugins {PLUGINS.names('algorithms')}"
|
|
334
|
+
)
|
|
335
|
+
if isinstance(spec, dict):
|
|
336
|
+
params = dict(spec)
|
|
337
|
+
name = params.pop("name", "wait")
|
|
338
|
+
cls = ALGORITHMS.get(name)
|
|
339
|
+
if cls is None:
|
|
340
|
+
from .plugins import PLUGINS
|
|
341
|
+
|
|
342
|
+
plugin = PLUGINS.algorithm(name)
|
|
343
|
+
if plugin is not None:
|
|
344
|
+
if params:
|
|
345
|
+
raise PoolNotFound(
|
|
346
|
+
f"algorithm plugin {name!r} does not take parameters: {sorted(params)}"
|
|
347
|
+
)
|
|
348
|
+
return plugin
|
|
349
|
+
raise PoolNotFound(f"unknown acquire algorithm: {name!r}, choices: {sorted(ALGORITHMS)}")
|
|
350
|
+
if "fallback" in params and isinstance(params["fallback"], (str, dict)):
|
|
351
|
+
params["fallback"] = resolve_algorithm(params["fallback"])
|
|
352
|
+
return cls(**params)
|
|
353
|
+
if isinstance(spec, AcquireAlgorithm):
|
|
354
|
+
return spec
|
|
355
|
+
raise PoolNotFound(f"cannot resolve acquire algorithm: {spec!r}")
|