acty 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.
- acty-0.1.0/.gitignore +8 -0
- acty-0.1.0/LICENSE +21 -0
- acty-0.1.0/PKG-INFO +115 -0
- acty-0.1.0/README.md +81 -0
- acty-0.1.0/examples/group_tui_demo.py +378 -0
- acty-0.1.0/examples/lane_demo.py +37 -0
- acty-0.1.0/examples/tenacity_retry_demo.py +91 -0
- acty-0.1.0/examples/tui_console_bindings_manual_check.md +35 -0
- acty-0.1.0/examples/tui_console_bindings_manual_check.py +119 -0
- acty-0.1.0/examples/tui_console_capture_demo.py +69 -0
- acty-0.1.0/pyproject.toml +53 -0
- acty-0.1.0/pytest.ini +4 -0
- acty-0.1.0/src/acty/__init__.py +103 -0
- acty-0.1.0/src/acty/addons/__init__.py +1 -0
- acty-0.1.0/src/acty/addons/langchain/__init__.py +30 -0
- acty-0.1.0/src/acty/addons/langchain/messages.py +20 -0
- acty-0.1.0/src/acty/client.py +129 -0
- acty-0.1.0/src/acty/engine.py +1595 -0
- acty-0.1.0/src/acty/exec_resolver.py +138 -0
- acty-0.1.0/src/acty/executors.py +776 -0
- acty-0.1.0/src/acty/groups.py +139 -0
- acty-0.1.0/src/acty/lane.py +140 -0
- acty-0.1.0/src/acty/logging.py +39 -0
- acty-0.1.0/src/acty/result_handlers.py +28 -0
- acty-0.1.0/src/acty/retry_budget.py +33 -0
- acty-0.1.0/src/acty/tui/__init__.py +52 -0
- acty-0.1.0/src/acty/tui/app.py +733 -0
- acty-0.1.0/src/acty/tui/cli.py +92 -0
- acty-0.1.0/src/acty/tui/console_capture.py +237 -0
- acty-0.1.0/src/acty/tui/console_guardrails.py +37 -0
- acty-0.1.0/src/acty/tui/formatting.py +162 -0
- acty-0.1.0/src/acty/tui/history.py +90 -0
- acty-0.1.0/src/acty/tui/runtime_helpers.py +116 -0
- acty-0.1.0/src/acty/tui/selectable_rich_log.py +119 -0
- acty-0.1.0/src/acty/tui/sources/__init__.py +13 -0
- acty-0.1.0/src/acty/tui/sources/base.py +11 -0
- acty-0.1.0/src/acty/tui/sources/file.py +128 -0
- acty-0.1.0/src/acty/tui/sources/queue.py +25 -0
- acty-0.1.0/src/acty/tui/sources/replay.py +65 -0
- acty-0.1.0/src/acty/tui/state.py +43 -0
- acty-0.1.0/src/acty/tui/widgets/__init__.py +20 -0
- acty-0.1.0/src/acty/tui/widgets/burst_indicator.py +54 -0
- acty-0.1.0/src/acty/tui/widgets/diagnostic_widget.py +175 -0
- acty-0.1.0/src/acty/tui/widgets/queue_depth_plot.py +43 -0
- acty-0.1.0/src/acty/tui/widgets/throughput_plot.py +41 -0
- acty-0.1.0/src/acty/tui/widgets/timeline.py +1581 -0
- acty-0.1.0/src/acty/tui/widgets/work_structure.py +221 -0
- acty-0.1.0/src/acty/tui/widgets/worker_pool.py +65 -0
- acty-0.1.0/tests/__init__.py +1 -0
- acty-0.1.0/tests/conftest.py +14 -0
- acty-0.1.0/tests/support/__init__.py +1 -0
- acty-0.1.0/tests/support/asyncio_tools.py +77 -0
- acty-0.1.0/tests/support/tui_harness.py +17 -0
- acty-0.1.0/tests/test_acty_client.py +69 -0
- acty-0.1.0/tests/test_acty_client_integration.py +151 -0
- acty-0.1.0/tests/test_compose_handlers.py +96 -0
- acty-0.1.0/tests/test_context_propagation.py +114 -0
- acty-0.1.0/tests/test_deferred_primer.py +165 -0
- acty-0.1.0/tests/test_engine_admission.py +261 -0
- acty-0.1.0/tests/test_engine_boundary_edge_cases.py +16 -0
- acty-0.1.0/tests/test_engine_cache_integration.py +403 -0
- acty-0.1.0/tests/test_engine_close_all_groups.py +143 -0
- acty-0.1.0/tests/test_engine_dispatch_defaults.py +16 -0
- acty-0.1.0/tests/test_engine_policy_matrix.py +566 -0
- acty-0.1.0/tests/test_engine_resource_management.py +48 -0
- acty-0.1.0/tests/test_engine_results.py +489 -0
- acty-0.1.0/tests/test_engine_retry_policies.py +263 -0
- acty-0.1.0/tests/test_error_payload_policy_runtime.py +124 -0
- acty-0.1.0/tests/test_event_invoke_kwargs.py +62 -0
- acty-0.1.0/tests/test_event_observability.py +59 -0
- acty-0.1.0/tests/test_group_context.py +69 -0
- acty-0.1.0/tests/test_group_dependencies.py +582 -0
- acty-0.1.0/tests/test_group_leases.py +336 -0
- acty-0.1.0/tests/test_group_registry.py +143 -0
- acty-0.1.0/tests/test_group_registry_integration.py +132 -0
- acty-0.1.0/tests/test_integration_pipeline.py +198 -0
- acty-0.1.0/tests/test_lane.py +556 -0
- acty-0.1.0/tests/test_lane_system.py +258 -0
- acty-0.1.0/tests/test_open_group_api.py +111 -0
- acty-0.1.0/tests/test_resolver_executor.py +132 -0
- acty-0.1.0/tests/test_result_handlers.py +849 -0
- acty-0.1.0/tests/test_retry_budget.py +37 -0
- acty-0.1.0/tests/test_retry_payload_resolver.py +129 -0
- acty-0.1.0/tests/test_runtime_stats.py +74 -0
- acty-0.1.0/tests/test_tenacity_retry_adapter.py +377 -0
- acty-0.1.0/tests/test_tui_app_flow.py +147 -0
- acty-0.1.0/tests/test_tui_cli.py +94 -0
- acty-0.1.0/tests/test_tui_console_capture.py +321 -0
- acty-0.1.0/tests/test_tui_demo_cli.py +56 -0
- acty-0.1.0/tests/test_tui_formatting.py +83 -0
- acty-0.1.0/tests/test_tui_history.py +57 -0
- acty-0.1.0/tests/test_tui_replay_integration.py +37 -0
- acty-0.1.0/tests/test_tui_selectable_rich_log.py +87 -0
- acty-0.1.0/tests/test_tui_sources.py +204 -0
- acty-0.1.0/tests/test_tui_state.py +127 -0
- acty-0.1.0/tests/test_tui_timeline.py +198 -0
- acty-0.1.0/tests/test_tui_widgets.py +88 -0
acty-0.1.0/.gitignore
ADDED
acty-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Konstantin Polev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
acty-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: acty
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: High-level group runtime with TUI and CLI built on top of acty-core
|
|
5
|
+
Project-URL: Homepage, https://github.com/conspol/acty
|
|
6
|
+
Project-URL: Repository, https://github.com/conspol/acty
|
|
7
|
+
Project-URL: Issues, https://github.com/conspol/acty/issues
|
|
8
|
+
Maintainer-email: Konstantin Polev <70580603+conspol@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: asyncio,runtime,scheduler,tui,workflow
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Topic :: Terminals
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: acty-core<0.2.0,>=0.1.0
|
|
23
|
+
Requires-Dist: tenacity>=8.2.0
|
|
24
|
+
Requires-Dist: textual-plotext>=1.0.1
|
|
25
|
+
Requires-Dist: textual>=6.11.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build>=1.2.2; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=9.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine>=5.1.1; extra == 'dev'
|
|
31
|
+
Provides-Extra: langchain
|
|
32
|
+
Requires-Dist: acty-langchain<0.2.0,>=0.1.0; extra == 'langchain'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# acty
|
|
36
|
+
|
|
37
|
+
`acty` is the high-level runtime package in the Acty stack. It builds on
|
|
38
|
+
`acty-core` and adds the engine API, TUI application, and the `acty-tui` CLI
|
|
39
|
+
for following or replaying JSONL event streams.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install acty
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Install the optional LangChain integration:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install "acty[langchain]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For local development:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install -e .[dev]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Python Usage
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import asyncio
|
|
63
|
+
|
|
64
|
+
from acty import ActyEngine, EngineConfig, EchoExecutor
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def main() -> None:
|
|
68
|
+
engine = ActyEngine(executor=EchoExecutor(), config=EngineConfig())
|
|
69
|
+
try:
|
|
70
|
+
submission = await engine.submit_group("demo", {"primer": 1}, [{"follower": 1}])
|
|
71
|
+
if submission.primer is not None:
|
|
72
|
+
print((await submission.primer).output)
|
|
73
|
+
for fut in submission.followers:
|
|
74
|
+
print((await fut).output)
|
|
75
|
+
finally:
|
|
76
|
+
await engine.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
asyncio.run(main())
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## CLI Usage
|
|
83
|
+
|
|
84
|
+
The package exposes `acty-tui`:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
acty-tui follow /tmp/acty_events.jsonl
|
|
88
|
+
acty-tui replay /tmp/acty_events.jsonl --speed 2.0
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Example Programs
|
|
92
|
+
|
|
93
|
+
Run the TUI demo:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python examples/group_tui_demo.py --groups 3 --followers-per-group 2
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Run the retry demo:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
python examples/tenacity_retry_demo.py --event-jsonl /tmp/acty_tenacity_events.jsonl
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Package Relationships
|
|
106
|
+
|
|
107
|
+
- `acty-core` provides the low-level scheduler, lifecycle, event, and cache primitives
|
|
108
|
+
- `acty` provides the engine, client, TUI, and CLI built on top of `acty-core`
|
|
109
|
+
- `acty-langchain`, `acty-openai`, and `acty-gigachat` provide optional executor integrations
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
- tests live under `tests/`
|
|
114
|
+
- example programs live under `examples/`
|
|
115
|
+
- adapter-specific integration coverage belongs in the adapter repos, not in this repo
|
acty-0.1.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# acty
|
|
2
|
+
|
|
3
|
+
`acty` is the high-level runtime package in the Acty stack. It builds on
|
|
4
|
+
`acty-core` and adds the engine API, TUI application, and the `acty-tui` CLI
|
|
5
|
+
for following or replaying JSONL event streams.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install acty
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Install the optional LangChain integration:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install "acty[langchain]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For local development:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install -e .[dev]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Python Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
|
|
30
|
+
from acty import ActyEngine, EngineConfig, EchoExecutor
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def main() -> None:
|
|
34
|
+
engine = ActyEngine(executor=EchoExecutor(), config=EngineConfig())
|
|
35
|
+
try:
|
|
36
|
+
submission = await engine.submit_group("demo", {"primer": 1}, [{"follower": 1}])
|
|
37
|
+
if submission.primer is not None:
|
|
38
|
+
print((await submission.primer).output)
|
|
39
|
+
for fut in submission.followers:
|
|
40
|
+
print((await fut).output)
|
|
41
|
+
finally:
|
|
42
|
+
await engine.close()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
asyncio.run(main())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## CLI Usage
|
|
49
|
+
|
|
50
|
+
The package exposes `acty-tui`:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
acty-tui follow /tmp/acty_events.jsonl
|
|
54
|
+
acty-tui replay /tmp/acty_events.jsonl --speed 2.0
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Example Programs
|
|
58
|
+
|
|
59
|
+
Run the TUI demo:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python examples/group_tui_demo.py --groups 3 --followers-per-group 2
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Run the retry demo:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python examples/tenacity_retry_demo.py --event-jsonl /tmp/acty_tenacity_events.jsonl
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Package Relationships
|
|
72
|
+
|
|
73
|
+
- `acty-core` provides the low-level scheduler, lifecycle, event, and cache primitives
|
|
74
|
+
- `acty` provides the engine, client, TUI, and CLI built on top of `acty-core`
|
|
75
|
+
- `acty-langchain`, `acty-openai`, and `acty-gigachat` provide optional executor integrations
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
- tests live under `tests/`
|
|
80
|
+
- example programs live under `examples/`
|
|
81
|
+
- adapter-specific integration coverage belongs in the adapter repos, not in this repo
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Group-only TUI demo harness for acty."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import contextlib
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
from acty import TuiState, create_tui_app
|
|
16
|
+
from acty.tui.sources import QueueEventSource
|
|
17
|
+
from acty_core.core.types import Job, JobResult
|
|
18
|
+
from acty_core.events import EventBus, JsonlEventSink
|
|
19
|
+
from acty_core.events.sinks.base import EventSink
|
|
20
|
+
from acty_core.events.types import Event
|
|
21
|
+
from acty_core.lifecycle import (
|
|
22
|
+
FollowerDispatchPolicy,
|
|
23
|
+
GroupLifecycleController,
|
|
24
|
+
GroupSpec,
|
|
25
|
+
GroupTaskKind,
|
|
26
|
+
)
|
|
27
|
+
from acty_core.results import AsyncQueueResultSink
|
|
28
|
+
from acty_core.scheduler import PoolConfig, WorkStealingScheduler
|
|
29
|
+
|
|
30
|
+
logger = structlog.get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EventQueueSink(EventSink):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
queue: asyncio.Queue[Event],
|
|
37
|
+
*,
|
|
38
|
+
drop_policy: str = "drop_oldest",
|
|
39
|
+
drop_log_interval_s: float = 2.0,
|
|
40
|
+
) -> None:
|
|
41
|
+
if drop_policy not in {"drop_oldest", "drop_newest", "block"}:
|
|
42
|
+
raise ValueError("drop_policy must be block, drop_oldest, or drop_newest")
|
|
43
|
+
self._queue = queue
|
|
44
|
+
self._drop_policy = drop_policy
|
|
45
|
+
self._dropped = 0
|
|
46
|
+
self._drop_log_interval_s = drop_log_interval_s
|
|
47
|
+
self._last_drop_log = 0.0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def dropped(self) -> int:
|
|
51
|
+
return self._dropped
|
|
52
|
+
|
|
53
|
+
async def handle(self, event: Event) -> None:
|
|
54
|
+
if self._drop_policy == "block":
|
|
55
|
+
await self._queue.put(event)
|
|
56
|
+
return
|
|
57
|
+
try:
|
|
58
|
+
self._queue.put_nowait(event)
|
|
59
|
+
except asyncio.QueueFull:
|
|
60
|
+
if self._drop_policy == "drop_newest":
|
|
61
|
+
self._dropped += 1
|
|
62
|
+
now = time.monotonic()
|
|
63
|
+
if now - self._last_drop_log >= self._drop_log_interval_s:
|
|
64
|
+
self._last_drop_log = now
|
|
65
|
+
logger.debug(
|
|
66
|
+
"Event queue full in demo sink",
|
|
67
|
+
drop_policy=self._drop_policy,
|
|
68
|
+
dropped=self._dropped,
|
|
69
|
+
queue_maxsize=self._queue.maxsize,
|
|
70
|
+
queue_size=self._queue.qsize(),
|
|
71
|
+
)
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
_ = self._queue.get_nowait()
|
|
75
|
+
self._queue.task_done()
|
|
76
|
+
self._dropped += 1
|
|
77
|
+
except asyncio.QueueEmpty:
|
|
78
|
+
self._dropped += 1
|
|
79
|
+
now = time.monotonic()
|
|
80
|
+
if now - self._last_drop_log >= self._drop_log_interval_s:
|
|
81
|
+
self._last_drop_log = now
|
|
82
|
+
logger.debug(
|
|
83
|
+
"Event queue empty while dropping oldest in demo sink",
|
|
84
|
+
drop_policy=self._drop_policy,
|
|
85
|
+
dropped=self._dropped,
|
|
86
|
+
queue_maxsize=self._queue.maxsize,
|
|
87
|
+
queue_size=self._queue.qsize(),
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
91
|
+
self._queue.put_nowait(event)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class DemoConfig:
|
|
96
|
+
groups: int = 3
|
|
97
|
+
followers_per_group: int = 5
|
|
98
|
+
open_groups: bool = False
|
|
99
|
+
warm_delay_s: float = 0.0
|
|
100
|
+
max_followers_inflight: int = 2
|
|
101
|
+
follower_dispatch_policy: FollowerDispatchPolicy = field(default_factory=FollowerDispatchPolicy)
|
|
102
|
+
primer_workers: int = 1
|
|
103
|
+
follower_workers: int = 2
|
|
104
|
+
min_delay_s: float = 0.01
|
|
105
|
+
max_delay_s: float = 0.05
|
|
106
|
+
event_jsonl: str | None = None
|
|
107
|
+
seed: int = 0
|
|
108
|
+
result_queue_size: int = 0
|
|
109
|
+
result_drop_policy: str = "drop_oldest"
|
|
110
|
+
event_queue_size: int = 5000
|
|
111
|
+
event_drop_policy: str = "drop_oldest"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DemoExecutor:
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
controller: GroupLifecycleController,
|
|
118
|
+
*,
|
|
119
|
+
rng: random.Random,
|
|
120
|
+
min_delay_s: float,
|
|
121
|
+
max_delay_s: float,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._controller = controller
|
|
124
|
+
self._rng = rng
|
|
125
|
+
self._min_delay_s = min_delay_s
|
|
126
|
+
self._max_delay_s = max_delay_s
|
|
127
|
+
|
|
128
|
+
async def execute(self, job: Job, *, pool: str) -> JobResult:
|
|
129
|
+
group_id = str(job.group_id) if job.group_id is not None else ""
|
|
130
|
+
delay = self._rng.uniform(self._min_delay_s, self._max_delay_s)
|
|
131
|
+
if job.kind == GroupTaskKind.PRIMER.value:
|
|
132
|
+
await self._controller.mark_primer_started(group_id)
|
|
133
|
+
await asyncio.sleep(delay)
|
|
134
|
+
await self._controller.mark_primer_done(group_id)
|
|
135
|
+
elif job.kind == GroupTaskKind.FOLLOWER.value:
|
|
136
|
+
await asyncio.sleep(delay)
|
|
137
|
+
await self._controller.mark_follower_done(group_id)
|
|
138
|
+
else:
|
|
139
|
+
await asyncio.sleep(delay)
|
|
140
|
+
return JobResult(job_id=job.id, kind=job.kind, ok=True, output={"payload": job.payload})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _drain_results(
|
|
144
|
+
queue: asyncio.Queue[JobResult],
|
|
145
|
+
done_event: asyncio.Event,
|
|
146
|
+
) -> tuple[int, int]:
|
|
147
|
+
ok = 0
|
|
148
|
+
failed = 0
|
|
149
|
+
last_timeout_log = 0.0
|
|
150
|
+
log_interval_s = 2.0
|
|
151
|
+
while True:
|
|
152
|
+
if done_event.is_set() and queue.empty():
|
|
153
|
+
break
|
|
154
|
+
try:
|
|
155
|
+
result = await asyncio.wait_for(queue.get(), timeout=0.1)
|
|
156
|
+
except asyncio.TimeoutError:
|
|
157
|
+
now = time.monotonic()
|
|
158
|
+
if now - last_timeout_log >= log_interval_s:
|
|
159
|
+
last_timeout_log = now
|
|
160
|
+
logger.debug(
|
|
161
|
+
"Result queue wait timed out; continuing",
|
|
162
|
+
done_event_set=done_event.is_set(),
|
|
163
|
+
queue_size=queue.qsize(),
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
if result.ok:
|
|
167
|
+
ok += 1
|
|
168
|
+
else:
|
|
169
|
+
failed += 1
|
|
170
|
+
queue.task_done()
|
|
171
|
+
return ok, failed
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def run_tui_demo(config: DemoConfig) -> None:
|
|
175
|
+
event_queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=config.event_queue_size)
|
|
176
|
+
queue_sink = EventQueueSink(event_queue, drop_policy=config.event_drop_policy)
|
|
177
|
+
|
|
178
|
+
sinks: list[EventSink] = [queue_sink]
|
|
179
|
+
jsonl_sink = None
|
|
180
|
+
if config.event_jsonl:
|
|
181
|
+
jsonl_sink = JsonlEventSink(Path(config.event_jsonl))
|
|
182
|
+
sinks.append(jsonl_sink)
|
|
183
|
+
|
|
184
|
+
event_bus = EventBus(sinks)
|
|
185
|
+
|
|
186
|
+
result_sink = AsyncQueueResultSink(
|
|
187
|
+
maxsize=config.result_queue_size,
|
|
188
|
+
drop_policy=config.result_drop_policy,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
controller = GroupLifecycleController(
|
|
192
|
+
event_bus=event_bus,
|
|
193
|
+
follower_dispatch_policy=config.follower_dispatch_policy,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
scheduler = WorkStealingScheduler(
|
|
197
|
+
pools=[
|
|
198
|
+
PoolConfig(name="primer", preferred_kinds=[GroupTaskKind.PRIMER.value], workers=config.primer_workers),
|
|
199
|
+
PoolConfig(
|
|
200
|
+
name="follower",
|
|
201
|
+
preferred_kinds=[GroupTaskKind.FOLLOWER.value],
|
|
202
|
+
workers=config.follower_workers,
|
|
203
|
+
),
|
|
204
|
+
],
|
|
205
|
+
event_bus=event_bus,
|
|
206
|
+
result_sink=result_sink,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
rng = random.Random(config.seed)
|
|
210
|
+
executor = DemoExecutor(
|
|
211
|
+
controller,
|
|
212
|
+
rng=rng,
|
|
213
|
+
min_delay_s=config.min_delay_s,
|
|
214
|
+
max_delay_s=config.max_delay_s,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
group_ids: list[str] = []
|
|
218
|
+
for idx in range(config.groups):
|
|
219
|
+
group_id = f"group-{idx + 1}"
|
|
220
|
+
spec = GroupSpec(
|
|
221
|
+
group_id=group_id,
|
|
222
|
+
followers_total=None if config.open_groups else config.followers_per_group,
|
|
223
|
+
warm_delay_s=config.warm_delay_s,
|
|
224
|
+
max_followers_inflight=config.max_followers_inflight,
|
|
225
|
+
payload={"group_id": group_id, "primer": True},
|
|
226
|
+
)
|
|
227
|
+
await controller.add_group(spec)
|
|
228
|
+
if config.open_groups:
|
|
229
|
+
payloads = [
|
|
230
|
+
{"group_id": group_id, "follower": i + 1}
|
|
231
|
+
for i in range(config.followers_per_group)
|
|
232
|
+
]
|
|
233
|
+
await controller.add_followers(group_id, payloads)
|
|
234
|
+
await controller.close_group(group_id)
|
|
235
|
+
group_ids.append(group_id)
|
|
236
|
+
|
|
237
|
+
async def _wait_groups() -> None:
|
|
238
|
+
await asyncio.gather(*(controller.wait_group_done(gid) for gid in group_ids))
|
|
239
|
+
await controller.close()
|
|
240
|
+
|
|
241
|
+
app = create_tui_app(
|
|
242
|
+
QueueEventSource(event_queue),
|
|
243
|
+
state=TuiState(),
|
|
244
|
+
queue_maxsize=config.event_queue_size,
|
|
245
|
+
)
|
|
246
|
+
app_task = asyncio.create_task(app.run_async())
|
|
247
|
+
scheduler_task = asyncio.create_task(scheduler.run(controller, executor))
|
|
248
|
+
close_task = asyncio.create_task(_wait_groups())
|
|
249
|
+
|
|
250
|
+
done_event = asyncio.Event()
|
|
251
|
+
results_task = asyncio.create_task(_drain_results(result_sink.queue, done_event))
|
|
252
|
+
|
|
253
|
+
done, _ = await asyncio.wait({app_task, scheduler_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
254
|
+
|
|
255
|
+
if scheduler_task in done:
|
|
256
|
+
try:
|
|
257
|
+
report = scheduler_task.result()
|
|
258
|
+
except Exception:
|
|
259
|
+
app.exit()
|
|
260
|
+
await app_task
|
|
261
|
+
close_task.cancel()
|
|
262
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
263
|
+
await close_task
|
|
264
|
+
if jsonl_sink is not None:
|
|
265
|
+
jsonl_sink.close()
|
|
266
|
+
raise
|
|
267
|
+
done_event.set()
|
|
268
|
+
ok, failed = await results_task
|
|
269
|
+
app.exit()
|
|
270
|
+
await app_task
|
|
271
|
+
close_task.cancel()
|
|
272
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
273
|
+
await close_task
|
|
274
|
+
if jsonl_sink is not None:
|
|
275
|
+
jsonl_sink.close()
|
|
276
|
+
print("Run complete.")
|
|
277
|
+
print(f"Queued: {report.queued}")
|
|
278
|
+
print(f"Finished: {report.finished}")
|
|
279
|
+
print(f"Failed: {report.failed}")
|
|
280
|
+
print(f"Results OK: {ok} Failed: {failed} Dropped: {result_sink.dropped}")
|
|
281
|
+
if queue_sink.dropped:
|
|
282
|
+
print(f"Events dropped: {queue_sink.dropped}")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
done_event.set()
|
|
286
|
+
results_task.cancel()
|
|
287
|
+
close_task.cancel()
|
|
288
|
+
scheduler_task.cancel()
|
|
289
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
290
|
+
await results_task
|
|
291
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
292
|
+
await close_task
|
|
293
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
294
|
+
await scheduler_task
|
|
295
|
+
if jsonl_sink is not None:
|
|
296
|
+
jsonl_sink.close()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
300
|
+
parser = argparse.ArgumentParser(description="Run group-only runtime with the acty TUI.")
|
|
301
|
+
parser.add_argument("--groups", type=int, default=3)
|
|
302
|
+
parser.add_argument("--followers-per-group", type=int, default=5)
|
|
303
|
+
parser.add_argument("--open-groups", action="store_true")
|
|
304
|
+
parser.add_argument("--warm-delay-s", type=float, default=0.0)
|
|
305
|
+
parser.add_argument("--max-followers-inflight", type=int, default=2)
|
|
306
|
+
parser.add_argument("--dispatch-mode", type=str, default="target", choices=["serial", "target", "eager"])
|
|
307
|
+
parser.add_argument("--dispatch-target-total", type=int, default=None)
|
|
308
|
+
parser.add_argument("--dispatch-min-per-group", type=int, default=0)
|
|
309
|
+
parser.add_argument("--dispatch-max-per-group", type=int, default=None)
|
|
310
|
+
parser.add_argument("--primer-workers", type=int, default=1)
|
|
311
|
+
parser.add_argument("--follower-workers", type=int, default=2)
|
|
312
|
+
parser.add_argument("--min-delay-s", type=float, default=0.01)
|
|
313
|
+
parser.add_argument("--max-delay-s", type=float, default=0.05)
|
|
314
|
+
parser.add_argument("--event-jsonl", type=str, default="")
|
|
315
|
+
parser.add_argument("--seed", type=int, default=0)
|
|
316
|
+
parser.add_argument("--result-queue-size", type=int, default=0)
|
|
317
|
+
parser.add_argument(
|
|
318
|
+
"--result-drop-policy",
|
|
319
|
+
type=str,
|
|
320
|
+
default="drop_oldest",
|
|
321
|
+
choices=["block", "drop_newest", "drop_oldest"],
|
|
322
|
+
)
|
|
323
|
+
parser.add_argument("--event-queue-size", type=int, default=5000)
|
|
324
|
+
parser.add_argument(
|
|
325
|
+
"--event-drop-policy",
|
|
326
|
+
type=str,
|
|
327
|
+
default="drop_oldest",
|
|
328
|
+
choices=["block", "drop_newest", "drop_oldest"],
|
|
329
|
+
)
|
|
330
|
+
return parser
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _normalize_optional_int(value: int | None) -> int | None:
|
|
334
|
+
if value is None or value == 0:
|
|
335
|
+
return None
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def build_demo_config(args: argparse.Namespace) -> DemoConfig:
|
|
340
|
+
target_total = _normalize_optional_int(args.dispatch_target_total)
|
|
341
|
+
max_per_group = _normalize_optional_int(args.dispatch_max_per_group)
|
|
342
|
+
if args.dispatch_mode == "target" and target_total is None:
|
|
343
|
+
target_total = max(1, args.follower_workers)
|
|
344
|
+
return DemoConfig(
|
|
345
|
+
groups=args.groups,
|
|
346
|
+
followers_per_group=args.followers_per_group,
|
|
347
|
+
open_groups=args.open_groups,
|
|
348
|
+
warm_delay_s=args.warm_delay_s,
|
|
349
|
+
max_followers_inflight=args.max_followers_inflight,
|
|
350
|
+
follower_dispatch_policy=FollowerDispatchPolicy(
|
|
351
|
+
mode=args.dispatch_mode,
|
|
352
|
+
target_total=target_total,
|
|
353
|
+
min_per_group=args.dispatch_min_per_group,
|
|
354
|
+
max_per_group=max_per_group,
|
|
355
|
+
),
|
|
356
|
+
primer_workers=args.primer_workers,
|
|
357
|
+
follower_workers=args.follower_workers,
|
|
358
|
+
min_delay_s=args.min_delay_s,
|
|
359
|
+
max_delay_s=args.max_delay_s,
|
|
360
|
+
event_jsonl=args.event_jsonl or None,
|
|
361
|
+
seed=args.seed,
|
|
362
|
+
result_queue_size=args.result_queue_size,
|
|
363
|
+
result_drop_policy=args.result_drop_policy,
|
|
364
|
+
event_queue_size=args.event_queue_size,
|
|
365
|
+
event_drop_policy=args.event_drop_policy,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def main(argv: list[str] | None = None) -> int:
|
|
370
|
+
parser = build_parser()
|
|
371
|
+
args = parser.parse_args(argv)
|
|
372
|
+
config = build_demo_config(args)
|
|
373
|
+
asyncio.run(run_tui_demo(config))
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Lane-aware scheduling demo for Acty."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from acty import ActyEngine, EngineConfig, NoopExecutor
|
|
8
|
+
from acty_core.scheduler import LaneConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def main() -> None:
|
|
12
|
+
config = EngineConfig(
|
|
13
|
+
primer_workers=2,
|
|
14
|
+
follower_workers=2,
|
|
15
|
+
lane_configs={
|
|
16
|
+
"run-a": LaneConfig(weight=2),
|
|
17
|
+
"run-b": LaneConfig(weight=1),
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
engine = ActyEngine(executor=NoopExecutor(), config=config)
|
|
21
|
+
try:
|
|
22
|
+
lane_a = engine.lane("run-a")
|
|
23
|
+
lane_b = engine.lane("run-b")
|
|
24
|
+
registry_a = lane_a.registry(group_id_resolver=lambda key: f"{key[0]}:{key[1]}")
|
|
25
|
+
registry_b = lane_b.registry(group_id_resolver=lambda key: f"{key[0]}:{key[1]}")
|
|
26
|
+
|
|
27
|
+
handle_a = await registry_a.ensure(("lane-a", "group-1"))
|
|
28
|
+
handle_b = await registry_b.ensure(("lane-b", "group-1"))
|
|
29
|
+
|
|
30
|
+
await handle_a.submit_primer({"p": "a"})
|
|
31
|
+
await handle_b.submit_primer({"p": "b"})
|
|
32
|
+
finally:
|
|
33
|
+
await engine.close()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
asyncio.run(main())
|