acty-core 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_core-0.1.0/.gitignore +8 -0
- acty_core-0.1.0/LICENSE +21 -0
- acty_core-0.1.0/PKG-INFO +98 -0
- acty_core-0.1.0/README.md +67 -0
- acty_core-0.1.0/examples/attach_tui_later.md +19 -0
- acty_core-0.1.0/examples/group_headless.py +256 -0
- acty_core-0.1.0/examples/groups_with_cache_registry.py +168 -0
- acty_core-0.1.0/examples/scheduler_headless.py +174 -0
- acty_core-0.1.0/examples/scheduler_jsonl.py +186 -0
- acty_core-0.1.0/pyproject.toml +46 -0
- acty_core-0.1.0/pytest.ini +4 -0
- acty_core-0.1.0/src/acty_core/__init__.py +56 -0
- acty_core-0.1.0/src/acty_core/cache/__init__.py +19 -0
- acty_core-0.1.0/src/acty_core/cache/handle.py +31 -0
- acty_core-0.1.0/src/acty_core/cache/provider.py +19 -0
- acty_core-0.1.0/src/acty_core/cache/registry.py +308 -0
- acty_core-0.1.0/src/acty_core/cache/storage.py +23 -0
- acty_core-0.1.0/src/acty_core/cache/storage_memory.py +59 -0
- acty_core-0.1.0/src/acty_core/cache/storage_sqlite.py +186 -0
- acty_core-0.1.0/src/acty_core/context.py +31 -0
- acty_core-0.1.0/src/acty_core/core/__init__.py +17 -0
- acty_core-0.1.0/src/acty_core/core/errors.py +17 -0
- acty_core-0.1.0/src/acty_core/core/types.py +53 -0
- acty_core-0.1.0/src/acty_core/events/__init__.py +30 -0
- acty_core-0.1.0/src/acty_core/events/bus.py +210 -0
- acty_core-0.1.0/src/acty_core/events/collector.py +620 -0
- acty_core-0.1.0/src/acty_core/events/payloads.py +212 -0
- acty_core-0.1.0/src/acty_core/events/sinks/base.py +11 -0
- acty_core-0.1.0/src/acty_core/events/sinks/jsonl.py +27 -0
- acty_core-0.1.0/src/acty_core/events/sinks/otel.py +204 -0
- acty_core-0.1.0/src/acty_core/events/sinks/prometheus.py +220 -0
- acty_core-0.1.0/src/acty_core/events/sinks/queue.py +19 -0
- acty_core-0.1.0/src/acty_core/events/snapshot.py +56 -0
- acty_core-0.1.0/src/acty_core/events/types.py +40 -0
- acty_core-0.1.0/src/acty_core/executors.py +1370 -0
- acty_core-0.1.0/src/acty_core/lifecycle/__init__.py +49 -0
- acty_core-0.1.0/src/acty_core/lifecycle/admission.py +592 -0
- acty_core-0.1.0/src/acty_core/lifecycle/burst.py +129 -0
- acty_core-0.1.0/src/acty_core/lifecycle/controller.py +1983 -0
- acty_core-0.1.0/src/acty_core/lifecycle/dependency_gate.py +844 -0
- acty_core-0.1.0/src/acty_core/lifecycle/dispatch.py +81 -0
- acty_core-0.1.0/src/acty_core/lifecycle/failure.py +56 -0
- acty_core-0.1.0/src/acty_core/lifecycle/groups.py +139 -0
- acty_core-0.1.0/src/acty_core/logging.py +192 -0
- acty_core-0.1.0/src/acty_core/result_handlers.py +108 -0
- acty_core-0.1.0/src/acty_core/results/__init__.py +17 -0
- acty_core-0.1.0/src/acty_core/results/async_queue.py +109 -0
- acty_core-0.1.0/src/acty_core/results/awaitable.py +151 -0
- acty_core-0.1.0/src/acty_core/results/base.py +11 -0
- acty_core-0.1.0/src/acty_core/results/fanout.py +60 -0
- acty_core-0.1.0/src/acty_core/results/in_memory.py +14 -0
- acty_core-0.1.0/src/acty_core/scheduler/__init__.py +29 -0
- acty_core-0.1.0/src/acty_core/scheduler/policies.py +36 -0
- acty_core-0.1.0/src/acty_core/scheduler/pool.py +13 -0
- acty_core-0.1.0/src/acty_core/scheduler/work_stealing.py +1505 -0
- acty_core-0.1.0/src/acty_core/telemetry/__init__.py +112 -0
- acty_core-0.1.0/src/acty_core/telemetry/acty_span.py +47 -0
- acty_core-0.1.0/src/acty_core/telemetry/llm_messages.py +274 -0
- acty_core-0.1.0/src/acty_core/telemetry/llm_tokens.py +123 -0
- acty_core-0.1.0/tests/__init__.py +1 -0
- acty_core-0.1.0/tests/conftest.py +5 -0
- acty_core-0.1.0/tests/support/__init__.py +1 -0
- acty_core-0.1.0/tests/support/asyncio_tools.py +77 -0
- acty_core-0.1.0/tests/support/event_bus_harness.py +69 -0
- acty_core-0.1.0/tests/support/scheduler_harness.py +33 -0
- acty_core-0.1.0/tests/support/time_control.py +38 -0
- acty_core-0.1.0/tests/test_acty_span.py +29 -0
- acty_core-0.1.0/tests/test_asyncio_tools.py +26 -0
- acty_core-0.1.0/tests/test_boundary_edge_cases.py +185 -0
- acty_core-0.1.0/tests/test_bounded_queue.py +39 -0
- acty_core-0.1.0/tests/test_burst_controller.py +112 -0
- acty_core-0.1.0/tests/test_cache_context_persistence.py +194 -0
- acty_core-0.1.0/tests/test_cache_integration.py +167 -0
- acty_core-0.1.0/tests/test_cache_interfaces.py +132 -0
- acty_core-0.1.0/tests/test_cache_registry.py +281 -0
- acty_core-0.1.0/tests/test_cache_registry_ttl.py +225 -0
- acty_core-0.1.0/tests/test_cache_storage_backends.py +93 -0
- acty_core-0.1.0/tests/test_concurrency_races.py +148 -0
- acty_core-0.1.0/tests/test_dependency_normalization.py +59 -0
- acty_core-0.1.0/tests/test_dispatch_policy.py +13 -0
- acty_core-0.1.0/tests/test_error_handling_failures.py +125 -0
- acty_core-0.1.0/tests/test_error_payload_policy.py +36 -0
- acty_core-0.1.0/tests/test_event_bus.py +76 -0
- acty_core-0.1.0/tests/test_event_queue_sink.py +35 -0
- acty_core-0.1.0/tests/test_executors.py +77 -0
- acty_core-0.1.0/tests/test_group_admission.py +212 -0
- acty_core-0.1.0/tests/test_group_failure_policies.py +220 -0
- acty_core-0.1.0/tests/test_group_lifecycle.py +256 -0
- acty_core-0.1.0/tests/test_headless_demo_cli.py +48 -0
- acty_core-0.1.0/tests/test_job_id_propagation.py +50 -0
- acty_core-0.1.0/tests/test_lane_targets.py +271 -0
- acty_core-0.1.0/tests/test_llm_messages.py +108 -0
- acty_core-0.1.0/tests/test_llm_tokens.py +42 -0
- acty_core-0.1.0/tests/test_logging_config.py +73 -0
- acty_core-0.1.0/tests/test_observability.py +159 -0
- acty_core-0.1.0/tests/test_open_groups.py +247 -0
- acty_core-0.1.0/tests/test_otel_event_integration.py +71 -0
- acty_core-0.1.0/tests/test_otel_event_sink.py +255 -0
- acty_core-0.1.0/tests/test_prometheus_metrics_sink.py +115 -0
- acty_core-0.1.0/tests/test_resource_management.py +170 -0
- acty_core-0.1.0/tests/test_resubmit_context_policy.py +115 -0
- acty_core-0.1.0/tests/test_result_handler_events.py +511 -0
- acty_core-0.1.0/tests/test_result_handler_otel.py +481 -0
- acty_core-0.1.0/tests/test_result_sinks.py +199 -0
- acty_core-0.1.0/tests/test_retry_integration.py +178 -0
- acty_core-0.1.0/tests/test_retry_result_streaming.py +95 -0
- acty_core-0.1.0/tests/test_scheduler.py +1274 -0
- acty_core-0.1.0/tests/test_scheduler_validation.py +11 -0
- acty_core-0.1.0/tests/test_scheduling_policy.py +47 -0
- acty_core-0.1.0/tests/test_state_machine.py +116 -0
- acty_core-0.1.0/tests/test_stats_collector.py +415 -0
- acty_core-0.1.0/tests/test_telemetry_privacy.py +93 -0
- acty_core-0.1.0/tests/test_work_stealing_behavior.py +191 -0
- acty_core-0.1.0/tests/utils.py +65 -0
acty_core-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_core-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: acty-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Group-only runtime core for scheduling, lifecycle, events, and cache primitives
|
|
5
|
+
Project-URL: Homepage, https://github.com/conspol/acty-core
|
|
6
|
+
Project-URL: Repository, https://github.com/conspol/acty-core
|
|
7
|
+
Project-URL: Issues, https://github.com/conspol/acty-core/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,workflow
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: rich>=13.0.0
|
|
22
|
+
Requires-Dist: structlog>=21.5.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build>=1.2.2; extra == 'dev'
|
|
25
|
+
Requires-Dist: opentelemetry-sdk>=1.39.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: prometheus-client>=0.22.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=9.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: twine>=5.1.1; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# acty-core
|
|
33
|
+
|
|
34
|
+
`acty-core` is the low-level runtime package behind the Acty ecosystem. It
|
|
35
|
+
provides the scheduler, lifecycle controller, event bus, cache primitives, and
|
|
36
|
+
lightweight demo executors without the higher-level TUI API.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install acty-core
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For local development:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install -e .[dev]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Example
|
|
51
|
+
|
|
52
|
+
Run the headless group demo:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python examples/group_headless.py --groups 3
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Run the scheduler-only demo:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python examples/scheduler_headless.py --groups 3
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Write JSONL events that can be inspected later:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python examples/scheduler_jsonl.py --event-jsonl /tmp/acty_events.jsonl
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## What It Includes
|
|
71
|
+
|
|
72
|
+
- `GroupLifecycleController` for primer/follower orchestration
|
|
73
|
+
- `WorkStealingScheduler` and pool configuration
|
|
74
|
+
- event bus primitives and JSONL-friendly event emission
|
|
75
|
+
- cache registry and cache-handle propagation support
|
|
76
|
+
- development executors such as `NoopExecutor`, `EchoExecutor`, and `SimulatedExecutor`
|
|
77
|
+
|
|
78
|
+
## Logging
|
|
79
|
+
|
|
80
|
+
`acty-core` uses `structlog`. For readable local logs and rich tracebacks:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from acty_core import configure_logging
|
|
84
|
+
|
|
85
|
+
configure_logging(handler="rich", show_locals=True)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Cache Behavior
|
|
89
|
+
|
|
90
|
+
When a group uses a cache registry, the lifecycle attaches the resolved cache
|
|
91
|
+
handle to queued jobs as `job.cache_handle`. Executors can reuse provider
|
|
92
|
+
metadata from the cache handle instead of creating duplicate cache entries.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
- tests live under `tests/`
|
|
97
|
+
- example programs live under `examples/`
|
|
98
|
+
- the package is intentionally usable without the higher-level `acty` TUI layer
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# acty-core
|
|
2
|
+
|
|
3
|
+
`acty-core` is the low-level runtime package behind the Acty ecosystem. It
|
|
4
|
+
provides the scheduler, lifecycle controller, event bus, cache primitives, and
|
|
5
|
+
lightweight demo executors without the higher-level TUI API.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install acty-core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For local development:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install -e .[dev]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Example
|
|
20
|
+
|
|
21
|
+
Run the headless group demo:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python examples/group_headless.py --groups 3
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Run the scheduler-only demo:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python examples/scheduler_headless.py --groups 3
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Write JSONL events that can be inspected later:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python examples/scheduler_jsonl.py --event-jsonl /tmp/acty_events.jsonl
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## What It Includes
|
|
40
|
+
|
|
41
|
+
- `GroupLifecycleController` for primer/follower orchestration
|
|
42
|
+
- `WorkStealingScheduler` and pool configuration
|
|
43
|
+
- event bus primitives and JSONL-friendly event emission
|
|
44
|
+
- cache registry and cache-handle propagation support
|
|
45
|
+
- development executors such as `NoopExecutor`, `EchoExecutor`, and `SimulatedExecutor`
|
|
46
|
+
|
|
47
|
+
## Logging
|
|
48
|
+
|
|
49
|
+
`acty-core` uses `structlog`. For readable local logs and rich tracebacks:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from acty_core import configure_logging
|
|
53
|
+
|
|
54
|
+
configure_logging(handler="rich", show_locals=True)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Cache Behavior
|
|
58
|
+
|
|
59
|
+
When a group uses a cache registry, the lifecycle attaches the resolved cache
|
|
60
|
+
handle to queued jobs as `job.cache_handle`. Executors can reuse provider
|
|
61
|
+
metadata from the cache handle instead of creating duplicate cache entries.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
- tests live under `tests/`
|
|
66
|
+
- example programs live under `examples/`
|
|
67
|
+
- the package is intentionally usable without the higher-level `acty` TUI layer
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Attach TUI Later (JSONL follow)
|
|
2
|
+
|
|
3
|
+
1) Run a long job and write JSONL events:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
python examples/scheduler_jsonl.py --event-jsonl /tmp/acty_events.jsonl
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
2) In another shell, attach the TUI and follow:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
acty-tui follow /tmp/acty_events.jsonl
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
3) For a completed run, replay:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
acty-tui replay /tmp/acty_events.jsonl --speed 2.0
|
|
19
|
+
```
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Group-only headless harness for acty-core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
|
|
14
|
+
from acty_core.core.types import Job, JobResult
|
|
15
|
+
from acty_core.events import EventBus, JsonlEventSink
|
|
16
|
+
from acty_core.lifecycle import (
|
|
17
|
+
FollowerDispatchPolicy,
|
|
18
|
+
GroupLifecycleController,
|
|
19
|
+
GroupSpec,
|
|
20
|
+
GroupTaskKind,
|
|
21
|
+
)
|
|
22
|
+
from acty_core.results import AsyncQueueResultSink
|
|
23
|
+
from acty_core.scheduler import PoolConfig, WorkStealingScheduler
|
|
24
|
+
|
|
25
|
+
logger = structlog.get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class DemoConfig:
|
|
30
|
+
groups: int = 3
|
|
31
|
+
followers_per_group: int = 5
|
|
32
|
+
open_groups: bool = False
|
|
33
|
+
warm_delay_s: float = 0.0
|
|
34
|
+
max_followers_inflight: int = 2
|
|
35
|
+
follower_dispatch_policy: FollowerDispatchPolicy = field(default_factory=FollowerDispatchPolicy)
|
|
36
|
+
primer_workers: int = 1
|
|
37
|
+
follower_workers: int = 2
|
|
38
|
+
min_delay_s: float = 0.01
|
|
39
|
+
max_delay_s: float = 0.05
|
|
40
|
+
event_jsonl: str | None = None
|
|
41
|
+
seed: int = 0
|
|
42
|
+
result_queue_size: int = 0
|
|
43
|
+
result_drop_policy: str = "drop_oldest"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DemoExecutor:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
controller: GroupLifecycleController,
|
|
50
|
+
*,
|
|
51
|
+
rng: random.Random,
|
|
52
|
+
min_delay_s: float,
|
|
53
|
+
max_delay_s: float,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._controller = controller
|
|
56
|
+
self._rng = rng
|
|
57
|
+
self._min_delay_s = min_delay_s
|
|
58
|
+
self._max_delay_s = max_delay_s
|
|
59
|
+
|
|
60
|
+
async def execute(self, job: Job, *, pool: str) -> JobResult:
|
|
61
|
+
group_id = str(job.group_id) if job.group_id is not None else ""
|
|
62
|
+
delay = self._rng.uniform(self._min_delay_s, self._max_delay_s)
|
|
63
|
+
if job.kind == GroupTaskKind.PRIMER.value:
|
|
64
|
+
await self._controller.mark_primer_started(group_id)
|
|
65
|
+
await asyncio.sleep(delay)
|
|
66
|
+
await self._controller.mark_primer_done(group_id)
|
|
67
|
+
elif job.kind == GroupTaskKind.FOLLOWER.value:
|
|
68
|
+
await asyncio.sleep(delay)
|
|
69
|
+
await self._controller.mark_follower_done(group_id)
|
|
70
|
+
else:
|
|
71
|
+
await asyncio.sleep(delay)
|
|
72
|
+
return JobResult(job_id=job.id, kind=job.kind, ok=True, output={"payload": job.payload})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _drain_results(
|
|
76
|
+
queue: asyncio.Queue[JobResult],
|
|
77
|
+
done_event: asyncio.Event,
|
|
78
|
+
) -> tuple[int, int]:
|
|
79
|
+
ok = 0
|
|
80
|
+
failed = 0
|
|
81
|
+
last_timeout_log = 0.0
|
|
82
|
+
log_interval_s = 2.0
|
|
83
|
+
while True:
|
|
84
|
+
if done_event.is_set() and queue.empty():
|
|
85
|
+
break
|
|
86
|
+
try:
|
|
87
|
+
result = await asyncio.wait_for(queue.get(), timeout=0.1)
|
|
88
|
+
except asyncio.TimeoutError:
|
|
89
|
+
now = time.monotonic()
|
|
90
|
+
if now - last_timeout_log >= log_interval_s:
|
|
91
|
+
last_timeout_log = now
|
|
92
|
+
logger.debug(
|
|
93
|
+
"Result queue wait timed out; continuing",
|
|
94
|
+
done_event_set=done_event.is_set(),
|
|
95
|
+
queue_size=queue.qsize(),
|
|
96
|
+
)
|
|
97
|
+
continue
|
|
98
|
+
if result.ok:
|
|
99
|
+
ok += 1
|
|
100
|
+
else:
|
|
101
|
+
failed += 1
|
|
102
|
+
queue.task_done()
|
|
103
|
+
return ok, failed
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def run_headless(config: DemoConfig) -> None:
|
|
107
|
+
sinks = []
|
|
108
|
+
jsonl_sink = None
|
|
109
|
+
if config.event_jsonl:
|
|
110
|
+
jsonl_sink = JsonlEventSink(Path(config.event_jsonl))
|
|
111
|
+
sinks.append(jsonl_sink)
|
|
112
|
+
event_bus = EventBus(sinks) if sinks else None
|
|
113
|
+
|
|
114
|
+
result_sink = AsyncQueueResultSink(
|
|
115
|
+
maxsize=config.result_queue_size,
|
|
116
|
+
drop_policy=config.result_drop_policy,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
controller = GroupLifecycleController(
|
|
120
|
+
event_bus=event_bus,
|
|
121
|
+
follower_dispatch_policy=config.follower_dispatch_policy,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
scheduler = WorkStealingScheduler(
|
|
125
|
+
pools=[
|
|
126
|
+
PoolConfig(name="primer", preferred_kinds=[GroupTaskKind.PRIMER.value], workers=config.primer_workers),
|
|
127
|
+
PoolConfig(
|
|
128
|
+
name="follower",
|
|
129
|
+
preferred_kinds=[GroupTaskKind.FOLLOWER.value],
|
|
130
|
+
workers=config.follower_workers,
|
|
131
|
+
),
|
|
132
|
+
],
|
|
133
|
+
event_bus=event_bus,
|
|
134
|
+
result_sink=result_sink,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
rng = random.Random(config.seed)
|
|
138
|
+
executor = DemoExecutor(
|
|
139
|
+
controller,
|
|
140
|
+
rng=rng,
|
|
141
|
+
min_delay_s=config.min_delay_s,
|
|
142
|
+
max_delay_s=config.max_delay_s,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
group_ids: list[str] = []
|
|
146
|
+
for idx in range(config.groups):
|
|
147
|
+
group_id = f"group-{idx + 1}"
|
|
148
|
+
spec = GroupSpec(
|
|
149
|
+
group_id=group_id,
|
|
150
|
+
followers_total=None if config.open_groups else config.followers_per_group,
|
|
151
|
+
warm_delay_s=config.warm_delay_s,
|
|
152
|
+
max_followers_inflight=config.max_followers_inflight,
|
|
153
|
+
payload={"group_id": group_id, "primer": True},
|
|
154
|
+
)
|
|
155
|
+
await controller.add_group(spec)
|
|
156
|
+
if config.open_groups:
|
|
157
|
+
payloads = [
|
|
158
|
+
{"group_id": group_id, "follower": i + 1}
|
|
159
|
+
for i in range(config.followers_per_group)
|
|
160
|
+
]
|
|
161
|
+
await controller.add_followers(group_id, payloads)
|
|
162
|
+
await controller.close_group(group_id)
|
|
163
|
+
group_ids.append(group_id)
|
|
164
|
+
|
|
165
|
+
scheduler_task = asyncio.create_task(scheduler.run(controller, executor))
|
|
166
|
+
|
|
167
|
+
done_event = asyncio.Event()
|
|
168
|
+
results_task = asyncio.create_task(_drain_results(result_sink.queue, done_event))
|
|
169
|
+
|
|
170
|
+
await asyncio.gather(*(controller.wait_group_done(gid) for gid in group_ids))
|
|
171
|
+
await controller.close()
|
|
172
|
+
report = await scheduler_task
|
|
173
|
+
done_event.set()
|
|
174
|
+
ok, failed = await results_task
|
|
175
|
+
|
|
176
|
+
if jsonl_sink is not None:
|
|
177
|
+
jsonl_sink.close()
|
|
178
|
+
|
|
179
|
+
print("Run complete.")
|
|
180
|
+
print(f"Queued: {report.queued}")
|
|
181
|
+
print(f"Finished: {report.finished}")
|
|
182
|
+
print(f"Failed: {report.failed}")
|
|
183
|
+
print(f"Results OK: {ok} Failed: {failed} Dropped: {result_sink.dropped}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
187
|
+
parser = argparse.ArgumentParser(description="Run acty-core group-only headless demo.")
|
|
188
|
+
parser.add_argument("--groups", type=int, default=3)
|
|
189
|
+
parser.add_argument("--followers-per-group", type=int, default=5)
|
|
190
|
+
parser.add_argument("--open-groups", action="store_true")
|
|
191
|
+
parser.add_argument("--warm-delay-s", type=float, default=0.0)
|
|
192
|
+
parser.add_argument("--max-followers-inflight", type=int, default=2)
|
|
193
|
+
parser.add_argument("--dispatch-mode", type=str, default="target", choices=["serial", "target", "eager"])
|
|
194
|
+
parser.add_argument("--dispatch-target-total", type=int, default=None)
|
|
195
|
+
parser.add_argument("--dispatch-min-per-group", type=int, default=0)
|
|
196
|
+
parser.add_argument("--dispatch-max-per-group", type=int, default=None)
|
|
197
|
+
parser.add_argument("--primer-workers", type=int, default=1)
|
|
198
|
+
parser.add_argument("--follower-workers", type=int, default=2)
|
|
199
|
+
parser.add_argument("--min-delay-s", type=float, default=0.01)
|
|
200
|
+
parser.add_argument("--max-delay-s", type=float, default=0.05)
|
|
201
|
+
parser.add_argument("--event-jsonl", type=str, default="")
|
|
202
|
+
parser.add_argument("--seed", type=int, default=0)
|
|
203
|
+
parser.add_argument("--result-queue-size", type=int, default=0)
|
|
204
|
+
parser.add_argument(
|
|
205
|
+
"--result-drop-policy",
|
|
206
|
+
type=str,
|
|
207
|
+
default="drop_oldest",
|
|
208
|
+
choices=["block", "drop_newest", "drop_oldest"],
|
|
209
|
+
)
|
|
210
|
+
return parser
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _normalize_optional_int(value: int | None) -> int | None:
|
|
214
|
+
if value is None or value == 0:
|
|
215
|
+
return None
|
|
216
|
+
return value
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def build_demo_config(args: argparse.Namespace) -> DemoConfig:
|
|
220
|
+
target_total = _normalize_optional_int(args.dispatch_target_total)
|
|
221
|
+
max_per_group = _normalize_optional_int(args.dispatch_max_per_group)
|
|
222
|
+
if args.dispatch_mode == "target" and target_total is None:
|
|
223
|
+
target_total = max(1, args.follower_workers)
|
|
224
|
+
return DemoConfig(
|
|
225
|
+
groups=args.groups,
|
|
226
|
+
followers_per_group=args.followers_per_group,
|
|
227
|
+
open_groups=args.open_groups,
|
|
228
|
+
warm_delay_s=args.warm_delay_s,
|
|
229
|
+
max_followers_inflight=args.max_followers_inflight,
|
|
230
|
+
follower_dispatch_policy=FollowerDispatchPolicy(
|
|
231
|
+
mode=args.dispatch_mode,
|
|
232
|
+
target_total=target_total,
|
|
233
|
+
min_per_group=args.dispatch_min_per_group,
|
|
234
|
+
max_per_group=max_per_group,
|
|
235
|
+
),
|
|
236
|
+
primer_workers=args.primer_workers,
|
|
237
|
+
follower_workers=args.follower_workers,
|
|
238
|
+
min_delay_s=args.min_delay_s,
|
|
239
|
+
max_delay_s=args.max_delay_s,
|
|
240
|
+
event_jsonl=args.event_jsonl or None,
|
|
241
|
+
seed=args.seed,
|
|
242
|
+
result_queue_size=args.result_queue_size,
|
|
243
|
+
result_drop_policy=args.result_drop_policy,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main(argv: list[str] | None = None) -> int:
|
|
248
|
+
parser = build_parser()
|
|
249
|
+
args = parser.parse_args(argv)
|
|
250
|
+
config = build_demo_config(args)
|
|
251
|
+
asyncio.run(run_headless(config))
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Group-only example using CacheRegistry with SQLite storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import random
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from acty_core.cache import CacheRegistry
|
|
11
|
+
from acty_core.cache.storage_sqlite import SQLiteStorage
|
|
12
|
+
from acty_core.core.types import Job, JobResult
|
|
13
|
+
from acty_core.lifecycle import GroupLifecycleController, GroupSpec, GroupTaskKind
|
|
14
|
+
from acty_core.scheduler import PoolConfig, WorkStealingScheduler
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DemoCacheProvider:
|
|
18
|
+
name = "demo"
|
|
19
|
+
|
|
20
|
+
def fingerprint(self, primer, context=None) -> str:
|
|
21
|
+
return f"{primer}:{context}"
|
|
22
|
+
|
|
23
|
+
async def create(self, primer, context=None) -> str | None:
|
|
24
|
+
return f"ref:{primer}"
|
|
25
|
+
|
|
26
|
+
async def wait_ready(self, provider_ref, timeout_s=None) -> None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
async def invalidate(self, provider_ref) -> None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DemoCounters:
|
|
35
|
+
primer_jobs: int = 0
|
|
36
|
+
follower_jobs: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class DemoConfig:
|
|
41
|
+
followers_per_group: int = 3
|
|
42
|
+
primer_workers: int = 1
|
|
43
|
+
follower_workers: int = 2
|
|
44
|
+
min_delay_s: float = 0.01
|
|
45
|
+
max_delay_s: float = 0.05
|
|
46
|
+
seed: int = 0
|
|
47
|
+
cache_path: str = "cache.sqlite"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DemoExecutor:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
controller: GroupLifecycleController,
|
|
54
|
+
counters: DemoCounters,
|
|
55
|
+
*,
|
|
56
|
+
rng: random.Random,
|
|
57
|
+
min_delay_s: float,
|
|
58
|
+
max_delay_s: float,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._controller = controller
|
|
61
|
+
self._counters = counters
|
|
62
|
+
self._rng = rng
|
|
63
|
+
self._min_delay_s = min_delay_s
|
|
64
|
+
self._max_delay_s = max_delay_s
|
|
65
|
+
|
|
66
|
+
async def execute(self, job: Job, *, pool: str) -> JobResult:
|
|
67
|
+
group_id = str(job.group_id) if job.group_id is not None else ""
|
|
68
|
+
delay = self._rng.uniform(self._min_delay_s, self._max_delay_s)
|
|
69
|
+
if job.kind == GroupTaskKind.PRIMER.value:
|
|
70
|
+
self._counters.primer_jobs += 1
|
|
71
|
+
await self._controller.mark_primer_started(group_id)
|
|
72
|
+
await asyncio.sleep(delay)
|
|
73
|
+
await self._controller.mark_primer_done(group_id)
|
|
74
|
+
elif job.kind == GroupTaskKind.FOLLOWER.value:
|
|
75
|
+
self._counters.follower_jobs += 1
|
|
76
|
+
await asyncio.sleep(delay)
|
|
77
|
+
await self._controller.mark_follower_done(group_id)
|
|
78
|
+
else:
|
|
79
|
+
await asyncio.sleep(delay)
|
|
80
|
+
return JobResult(job_id=job.id, kind=job.kind, ok=True, output={"payload": job.payload})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def run_pass(label: str, registry: CacheRegistry, config: DemoConfig) -> None:
|
|
84
|
+
controller = GroupLifecycleController(cache_registry=registry)
|
|
85
|
+
scheduler = WorkStealingScheduler(
|
|
86
|
+
pools=[
|
|
87
|
+
PoolConfig(name="primer", preferred_kinds=[GroupTaskKind.PRIMER.value], workers=config.primer_workers),
|
|
88
|
+
PoolConfig(
|
|
89
|
+
name="follower",
|
|
90
|
+
preferred_kinds=[GroupTaskKind.FOLLOWER.value],
|
|
91
|
+
workers=config.follower_workers,
|
|
92
|
+
),
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
counters = DemoCounters()
|
|
97
|
+
rng = random.Random(config.seed)
|
|
98
|
+
executor = DemoExecutor(
|
|
99
|
+
controller,
|
|
100
|
+
counters,
|
|
101
|
+
rng=rng,
|
|
102
|
+
min_delay_s=config.min_delay_s,
|
|
103
|
+
max_delay_s=config.max_delay_s,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
group_id = f"{label}-group"
|
|
107
|
+
await controller.add_group(
|
|
108
|
+
GroupSpec(
|
|
109
|
+
group_id=group_id,
|
|
110
|
+
followers_total=config.followers_per_group,
|
|
111
|
+
payload={"group_id": group_id},
|
|
112
|
+
cache_group_id="shared-cache",
|
|
113
|
+
primer_content="primer-v1",
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
await scheduler.run(controller, executor)
|
|
118
|
+
await controller.wait_group_done(group_id)
|
|
119
|
+
await controller.close()
|
|
120
|
+
|
|
121
|
+
print(f"{label}: primer_jobs={counters.primer_jobs} follower_jobs={counters.follower_jobs}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def run_demo(config: DemoConfig) -> None:
|
|
125
|
+
storage = SQLiteStorage(config.cache_path)
|
|
126
|
+
provider = DemoCacheProvider()
|
|
127
|
+
|
|
128
|
+
registry = CacheRegistry(provider, storage=storage)
|
|
129
|
+
await run_pass("first", registry, config)
|
|
130
|
+
await storage.close()
|
|
131
|
+
|
|
132
|
+
# New registry + storage simulates a new process using the same SQLite cache.
|
|
133
|
+
storage = SQLiteStorage(config.cache_path)
|
|
134
|
+
registry2 = CacheRegistry(DemoCacheProvider(), storage=storage)
|
|
135
|
+
await run_pass("second", registry2, config)
|
|
136
|
+
await storage.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
140
|
+
parser = argparse.ArgumentParser(description="Run group-only demo with CacheRegistry + SQLite.")
|
|
141
|
+
parser.add_argument("--followers-per-group", type=int, default=3)
|
|
142
|
+
parser.add_argument("--primer-workers", type=int, default=1)
|
|
143
|
+
parser.add_argument("--follower-workers", type=int, default=2)
|
|
144
|
+
parser.add_argument("--min-delay-s", type=float, default=0.01)
|
|
145
|
+
parser.add_argument("--max-delay-s", type=float, default=0.05)
|
|
146
|
+
parser.add_argument("--seed", type=int, default=0)
|
|
147
|
+
parser.add_argument("--cache-path", type=str, default="cache.sqlite")
|
|
148
|
+
return parser
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main(argv: list[str] | None = None) -> int:
|
|
152
|
+
parser = build_parser()
|
|
153
|
+
args = parser.parse_args(argv)
|
|
154
|
+
config = DemoConfig(
|
|
155
|
+
followers_per_group=args.followers_per_group,
|
|
156
|
+
primer_workers=args.primer_workers,
|
|
157
|
+
follower_workers=args.follower_workers,
|
|
158
|
+
min_delay_s=args.min_delay_s,
|
|
159
|
+
max_delay_s=args.max_delay_s,
|
|
160
|
+
seed=args.seed,
|
|
161
|
+
cache_path=args.cache_path,
|
|
162
|
+
)
|
|
163
|
+
asyncio.run(run_demo(config))
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
raise SystemExit(main())
|