dr-queues 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.
- dr_queues/__init__.py +77 -0
- dr_queues/amqp/__init__.py +18 -0
- dr_queues/amqp/connection.py +194 -0
- dr_queues/amqp/queues.py +66 -0
- dr_queues/analysis/__init__.py +3 -0
- dr_queues/analysis/filter.py +19 -0
- dr_queues/cli/__init__.py +6 -0
- dr_queues/cli/commands.py +14 -0
- dr_queues/cli/demo.py +143 -0
- dr_queues/cli/stage_worker.py +132 -0
- dr_queues/demo_handlers.py +38 -0
- dr_queues/events/__init__.py +21 -0
- dr_queues/events/amqp.py +118 -0
- dr_queues/events/memory.py +45 -0
- dr_queues/events/mongo.py +63 -0
- dr_queues/events/schema.py +34 -0
- dr_queues/events/sink.py +14 -0
- dr_queues/manifest/__init__.py +29 -0
- dr_queues/manifest/manifest.py +110 -0
- dr_queues/pipeline/__init__.py +10 -0
- dr_queues/pipeline/job.py +47 -0
- dr_queues/pipeline/runner.py +207 -0
- dr_queues/pipeline/tap.py +102 -0
- dr_queues/pipeline/workers.py +155 -0
- dr_queues/py.typed +0 -0
- dr_queues/utils.py +10 -0
- dr_queues/workflow/__init__.py +16 -0
- dr_queues/workflow/definition.py +20 -0
- dr_queues/workflow/pipeline.py +61 -0
- dr_queues/workflow/registry.py +28 -0
- dr_queues-0.1.0.dist-info/METADATA +243 -0
- dr_queues-0.1.0.dist-info/RECORD +35 -0
- dr_queues-0.1.0.dist-info/WHEEL +4 -0
- dr_queues-0.1.0.dist-info/entry_points.txt +3 -0
- dr_queues-0.1.0.dist-info/licenses/LICENSE +21 -0
dr_queues/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from dr_queues.analysis.filter import filter_run_event_dicts, filter_run_events
|
|
6
|
+
from dr_queues.events import (
|
|
7
|
+
AmqpEventSink,
|
|
8
|
+
CompositeEventSink,
|
|
9
|
+
EventKind,
|
|
10
|
+
EventSink,
|
|
11
|
+
MemoryEventSink,
|
|
12
|
+
MongoEventSink,
|
|
13
|
+
PipelineEvent,
|
|
14
|
+
)
|
|
15
|
+
from dr_queues.manifest import (
|
|
16
|
+
RunManifest,
|
|
17
|
+
RunStageManifest,
|
|
18
|
+
format_worker_commands,
|
|
19
|
+
load_run_manifest,
|
|
20
|
+
manifest_path,
|
|
21
|
+
parse_workers_arg,
|
|
22
|
+
write_run_manifest,
|
|
23
|
+
)
|
|
24
|
+
from dr_queues.pipeline import (
|
|
25
|
+
JobEnvelope,
|
|
26
|
+
TerminalTap,
|
|
27
|
+
WorkerPool,
|
|
28
|
+
seed_jobs,
|
|
29
|
+
)
|
|
30
|
+
from dr_queues.pipeline.runner import (
|
|
31
|
+
run_in_process,
|
|
32
|
+
seed_manifest_jobs,
|
|
33
|
+
setup_run_queues,
|
|
34
|
+
spawn_all_stage_workers,
|
|
35
|
+
spawn_stage_worker_process,
|
|
36
|
+
)
|
|
37
|
+
from dr_queues.workflow import (
|
|
38
|
+
HandlerRegistry,
|
|
39
|
+
Pipeline,
|
|
40
|
+
PipelineDefinition,
|
|
41
|
+
PipelineLane,
|
|
42
|
+
PipelineStep,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AmqpEventSink",
|
|
47
|
+
"CompositeEventSink",
|
|
48
|
+
"EventKind",
|
|
49
|
+
"EventSink",
|
|
50
|
+
"HandlerRegistry",
|
|
51
|
+
"JobEnvelope",
|
|
52
|
+
"MemoryEventSink",
|
|
53
|
+
"MongoEventSink",
|
|
54
|
+
"Pipeline",
|
|
55
|
+
"PipelineDefinition",
|
|
56
|
+
"PipelineEvent",
|
|
57
|
+
"PipelineLane",
|
|
58
|
+
"PipelineStep",
|
|
59
|
+
"RunManifest",
|
|
60
|
+
"RunStageManifest",
|
|
61
|
+
"TerminalTap",
|
|
62
|
+
"WorkerPool",
|
|
63
|
+
"__version__",
|
|
64
|
+
"filter_run_event_dicts",
|
|
65
|
+
"filter_run_events",
|
|
66
|
+
"format_worker_commands",
|
|
67
|
+
"load_run_manifest",
|
|
68
|
+
"manifest_path",
|
|
69
|
+
"parse_workers_arg",
|
|
70
|
+
"run_in_process",
|
|
71
|
+
"seed_jobs",
|
|
72
|
+
"seed_manifest_jobs",
|
|
73
|
+
"setup_run_queues",
|
|
74
|
+
"spawn_all_stage_workers",
|
|
75
|
+
"spawn_stage_worker_process",
|
|
76
|
+
"write_run_manifest",
|
|
77
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dr_queues.amqp.connection import (
|
|
2
|
+
ChannelSession,
|
|
3
|
+
PikaDeliveryMode,
|
|
4
|
+
delivery_tag,
|
|
5
|
+
open_connection,
|
|
6
|
+
publish_job,
|
|
7
|
+
)
|
|
8
|
+
from dr_queues.amqp.queues import StageQueues, build_stage_queues
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ChannelSession",
|
|
12
|
+
"PikaDeliveryMode",
|
|
13
|
+
"StageQueues",
|
|
14
|
+
"build_stage_queues",
|
|
15
|
+
"delivery_tag",
|
|
16
|
+
"open_connection",
|
|
17
|
+
"publish_job",
|
|
18
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pika
|
|
9
|
+
import pika.adapters.blocking_connection as pika_blocking
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
DEFAULT_AMQP_URL = "amqp://guest:guest@localhost:5672/"
|
|
13
|
+
|
|
14
|
+
type PikaBlockingChannel = pika_blocking.BlockingChannel
|
|
15
|
+
type PikaBasicProperties = pika.spec.BasicProperties
|
|
16
|
+
type PikaDeliveryTag = int
|
|
17
|
+
type PikaDeliveryMethod = pika.spec.Basic.Deliver
|
|
18
|
+
type PikaGetOkMethod = pika.spec.Basic.GetOk
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PikaDeliveryMode(IntEnum):
|
|
22
|
+
TRANSIENT = pika.spec.TRANSIENT_DELIVERY_MODE
|
|
23
|
+
PERSISTENT = pika.spec.PERSISTENT_DELIVERY_MODE
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReceivedMessage(BaseModel):
|
|
27
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
28
|
+
|
|
29
|
+
method: PikaGetOkMethod | PikaDeliveryMethod | None
|
|
30
|
+
body: bytes = rb""
|
|
31
|
+
properties: PikaBasicProperties | None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_get_tuple(
|
|
35
|
+
cls,
|
|
36
|
+
method: Any,
|
|
37
|
+
properties: Any,
|
|
38
|
+
body: Any,
|
|
39
|
+
) -> ReceivedMessage:
|
|
40
|
+
return ReceivedMessage(
|
|
41
|
+
method=method,
|
|
42
|
+
body=body or b"",
|
|
43
|
+
properties=properties,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def has_messages(self) -> bool:
|
|
48
|
+
return self.method is not None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def payload(self) -> tuple[bytes, PikaBasicProperties | None]:
|
|
52
|
+
return (self.body, self.properties)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def delivery_tag(
|
|
56
|
+
method: PikaDeliveryMethod | PikaGetOkMethod,
|
|
57
|
+
) -> PikaDeliveryTag:
|
|
58
|
+
tag = method.delivery_tag
|
|
59
|
+
if tag is None:
|
|
60
|
+
msg = "Missing delivery tag on message."
|
|
61
|
+
raise RuntimeError(msg)
|
|
62
|
+
return tag
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def amqp_url() -> str:
|
|
66
|
+
return os.environ.get("AMQP_URL", DEFAULT_AMQP_URL)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@lru_cache(maxsize=1)
|
|
70
|
+
def _parameters() -> pika.URLParameters:
|
|
71
|
+
return pika.URLParameters(amqp_url())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def open_connection() -> pika_blocking.BlockingConnection:
|
|
75
|
+
return pika.BlockingConnection(_parameters())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def make_delivery_props(
|
|
79
|
+
delivery_mode: PikaDeliveryMode,
|
|
80
|
+
) -> pika.BasicProperties:
|
|
81
|
+
return pika.BasicProperties(delivery_mode=delivery_mode)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def publish_job(
|
|
85
|
+
channel: PikaBlockingChannel,
|
|
86
|
+
queue_name: str,
|
|
87
|
+
body: bytes,
|
|
88
|
+
*,
|
|
89
|
+
exchange: str = "",
|
|
90
|
+
delivery_mode: PikaDeliveryMode = PikaDeliveryMode.PERSISTENT,
|
|
91
|
+
properties: pika.spec.BasicProperties | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
props = properties or make_delivery_props(delivery_mode=delivery_mode)
|
|
94
|
+
if not channel.is_open:
|
|
95
|
+
raise RuntimeError("Channel is closed")
|
|
96
|
+
channel.basic_publish(
|
|
97
|
+
exchange=exchange,
|
|
98
|
+
routing_key=queue_name,
|
|
99
|
+
body=body,
|
|
100
|
+
properties=props,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ChannelSession:
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
connection: pika.BlockingConnection,
|
|
108
|
+
channel: PikaBlockingChannel,
|
|
109
|
+
delivery_mode: PikaDeliveryMode = PikaDeliveryMode.PERSISTENT,
|
|
110
|
+
):
|
|
111
|
+
self.connection = connection
|
|
112
|
+
self.channel = channel
|
|
113
|
+
self._delivery_mode = delivery_mode
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def open_session(
|
|
117
|
+
cls,
|
|
118
|
+
*,
|
|
119
|
+
delivery_mode: PikaDeliveryMode = PikaDeliveryMode.PERSISTENT,
|
|
120
|
+
) -> ChannelSession:
|
|
121
|
+
connection = open_connection()
|
|
122
|
+
return cls(
|
|
123
|
+
connection=connection,
|
|
124
|
+
channel=connection.channel(),
|
|
125
|
+
delivery_mode=delivery_mode,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def ensure_channel(
|
|
130
|
+
cls,
|
|
131
|
+
*,
|
|
132
|
+
channel: PikaBlockingChannel | None = None,
|
|
133
|
+
delivery_mode: PikaDeliveryMode = PikaDeliveryMode.PERSISTENT,
|
|
134
|
+
) -> tuple[ChannelSession | None, PikaBlockingChannel]:
|
|
135
|
+
session = None
|
|
136
|
+
if channel is None:
|
|
137
|
+
session = cls.open_session(delivery_mode=delivery_mode)
|
|
138
|
+
channel = session.channel
|
|
139
|
+
return session, channel
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def declare_durable_queue(
|
|
143
|
+
cls,
|
|
144
|
+
*,
|
|
145
|
+
queue_name: str,
|
|
146
|
+
channel: PikaBlockingChannel | None = None,
|
|
147
|
+
delivery_mode: PikaDeliveryMode = PikaDeliveryMode.PERSISTENT,
|
|
148
|
+
) -> None:
|
|
149
|
+
session = None
|
|
150
|
+
if channel is None:
|
|
151
|
+
session = cls.open_session(delivery_mode=delivery_mode)
|
|
152
|
+
channel = session.channel
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
channel.queue_declare(queue=queue_name, durable=True)
|
|
156
|
+
finally:
|
|
157
|
+
if session is not None:
|
|
158
|
+
session.close()
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def ensure_durable_queue(
|
|
162
|
+
cls,
|
|
163
|
+
*,
|
|
164
|
+
queue_name: str,
|
|
165
|
+
channel: PikaBlockingChannel | None = None,
|
|
166
|
+
delivery_mode: PikaDeliveryMode = PikaDeliveryMode.PERSISTENT,
|
|
167
|
+
) -> None:
|
|
168
|
+
ChannelSession.declare_durable_queue(
|
|
169
|
+
queue_name=queue_name,
|
|
170
|
+
channel=channel,
|
|
171
|
+
delivery_mode=delivery_mode,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def channel_props(self) -> pika.BasicProperties:
|
|
176
|
+
return make_delivery_props(delivery_mode=self._delivery_mode)
|
|
177
|
+
|
|
178
|
+
def publish_job(
|
|
179
|
+
self,
|
|
180
|
+
queue_name: str,
|
|
181
|
+
body: bytes,
|
|
182
|
+
) -> None:
|
|
183
|
+
publish_job(
|
|
184
|
+
channel=self.channel,
|
|
185
|
+
queue_name=queue_name,
|
|
186
|
+
body=body,
|
|
187
|
+
properties=self.channel_props,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
if self.channel.is_open:
|
|
192
|
+
self.channel.close()
|
|
193
|
+
if self.connection.is_open:
|
|
194
|
+
self.connection.close()
|
dr_queues/amqp/queues.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from dr_queues.amqp.connection import (
|
|
6
|
+
ChannelSession,
|
|
7
|
+
PikaBlockingChannel,
|
|
8
|
+
PikaDeliveryMode,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StageQueues(BaseModel):
|
|
13
|
+
prefix: str
|
|
14
|
+
delivery_mode: PikaDeliveryMode
|
|
15
|
+
pending_name: str
|
|
16
|
+
completed_name: str
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def get_queue_name(cls, prefix: str, role: str) -> str:
|
|
20
|
+
return f"{prefix}.{role}"
|
|
21
|
+
|
|
22
|
+
def declare_queues(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
channel: PikaBlockingChannel | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
build_queue_session, channel = ChannelSession.ensure_channel(
|
|
28
|
+
channel=channel,
|
|
29
|
+
delivery_mode=self.delivery_mode,
|
|
30
|
+
)
|
|
31
|
+
try:
|
|
32
|
+
ChannelSession.declare_durable_queue(
|
|
33
|
+
queue_name=self.pending_name,
|
|
34
|
+
channel=channel,
|
|
35
|
+
delivery_mode=self.delivery_mode,
|
|
36
|
+
)
|
|
37
|
+
if self.completed_name != self.pending_name:
|
|
38
|
+
ChannelSession.declare_durable_queue(
|
|
39
|
+
queue_name=self.completed_name,
|
|
40
|
+
channel=channel,
|
|
41
|
+
delivery_mode=self.delivery_mode,
|
|
42
|
+
)
|
|
43
|
+
finally:
|
|
44
|
+
if build_queue_session is not None:
|
|
45
|
+
build_queue_session.close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_stage_queues(
|
|
49
|
+
*,
|
|
50
|
+
prefix: str,
|
|
51
|
+
pending: str | None = None,
|
|
52
|
+
completed: str | None = None,
|
|
53
|
+
delivery_mode: PikaDeliveryMode = PikaDeliveryMode.PERSISTENT,
|
|
54
|
+
) -> StageQueues:
|
|
55
|
+
pending_name = pending or StageQueues.get_queue_name(prefix, "pending")
|
|
56
|
+
completed_name = completed or StageQueues.get_queue_name(
|
|
57
|
+
prefix, "completed"
|
|
58
|
+
)
|
|
59
|
+
stage_queues = StageQueues(
|
|
60
|
+
prefix=prefix,
|
|
61
|
+
delivery_mode=delivery_mode,
|
|
62
|
+
pending_name=pending_name,
|
|
63
|
+
completed_name=completed_name,
|
|
64
|
+
)
|
|
65
|
+
stage_queues.declare_queues()
|
|
66
|
+
return stage_queues
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dr_queues.events.schema import PipelineEvent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def filter_run_events(
|
|
9
|
+
events: list[PipelineEvent],
|
|
10
|
+
run_id: str,
|
|
11
|
+
) -> list[PipelineEvent]:
|
|
12
|
+
return [event for event in events if event.run_id == run_id]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def filter_run_event_dicts(
|
|
16
|
+
events: list[dict[str, Any]],
|
|
17
|
+
run_id: str,
|
|
18
|
+
) -> list[dict[str, Any]]:
|
|
19
|
+
return [event for event in events if event.get("run_id") == run_id]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
STAGE_WORKER_ENTRYPOINT = "dr-queues-stage-worker"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def stage_worker_command_prefix() -> list[str]:
|
|
10
|
+
"""Resolve the stage-worker CLI for installed or editable runs."""
|
|
11
|
+
executable = shutil.which(STAGE_WORKER_ENTRYPOINT)
|
|
12
|
+
if executable is not None:
|
|
13
|
+
return [executable]
|
|
14
|
+
return [sys.executable, "-m", "dr_queues.cli.stage_worker"]
|
dr_queues/cli/demo.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from dr_queues import (
|
|
11
|
+
AmqpEventSink,
|
|
12
|
+
CompositeEventSink,
|
|
13
|
+
EventKind,
|
|
14
|
+
MongoEventSink,
|
|
15
|
+
Pipeline,
|
|
16
|
+
PipelineDefinition,
|
|
17
|
+
PipelineLane,
|
|
18
|
+
PipelineStep,
|
|
19
|
+
filter_run_events,
|
|
20
|
+
manifest_path,
|
|
21
|
+
parse_workers_arg,
|
|
22
|
+
run_in_process,
|
|
23
|
+
seed_manifest_jobs,
|
|
24
|
+
setup_run_queues,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DEFAULT_WORKERS = "slow=4,transform=4,finalize=2"
|
|
28
|
+
HANDLERS_MODULE = "dr_queues.demo_handlers"
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(add_completion=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_registry(module_path: str):
|
|
34
|
+
module = importlib.import_module(module_path)
|
|
35
|
+
if hasattr(module, "registry"):
|
|
36
|
+
return module.registry
|
|
37
|
+
msg = f"Module {module_path!r} has no registry attribute."
|
|
38
|
+
raise typer.BadParameter(msg)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_pipeline(
|
|
42
|
+
*,
|
|
43
|
+
lanes: int,
|
|
44
|
+
handlers_module: str,
|
|
45
|
+
) -> Pipeline:
|
|
46
|
+
registry = _load_registry(handlers_module)
|
|
47
|
+
definition = PipelineDefinition(
|
|
48
|
+
id="demo_pipeline",
|
|
49
|
+
lanes=[PipelineLane(id=f"lane-{index}") for index in range(lanes)],
|
|
50
|
+
steps=[
|
|
51
|
+
PipelineStep(name="slow", handler_key="sleep_ms"),
|
|
52
|
+
PipelineStep(name="transform", handler_key="add_prefix"),
|
|
53
|
+
PipelineStep(name="finalize", handler_key="record_artifact"),
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
return Pipeline(definition, registry)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_event_sink(sink: str):
|
|
60
|
+
if sink == "mongo":
|
|
61
|
+
return MongoEventSink()
|
|
62
|
+
if sink == "amqp":
|
|
63
|
+
return AmqpEventSink()
|
|
64
|
+
if sink == "both":
|
|
65
|
+
return CompositeEventSink(
|
|
66
|
+
[MongoEventSink(), AmqpEventSink()],
|
|
67
|
+
)
|
|
68
|
+
msg = f"Unknown sink {sink!r}; expected mongo, amqp, or both."
|
|
69
|
+
raise typer.BadParameter(msg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def main(
|
|
74
|
+
repeats: int = typer.Option(2, "--repeats"),
|
|
75
|
+
lanes: int = typer.Option(2, "--lanes"),
|
|
76
|
+
workers: str = typer.Option(DEFAULT_WORKERS, "--workers"),
|
|
77
|
+
run_id: str | None = typer.Option(None, "--run-id"),
|
|
78
|
+
sink: str = typer.Option("mongo", "--sink"),
|
|
79
|
+
handlers_module: str = typer.Option(
|
|
80
|
+
HANDLERS_MODULE,
|
|
81
|
+
"--handlers-module",
|
|
82
|
+
),
|
|
83
|
+
completion_timeout: float = typer.Option(120.0, "--completion-timeout"),
|
|
84
|
+
) -> None:
|
|
85
|
+
resolved_run_id = run_id or f"demo-{uuid4().hex[:8]}"
|
|
86
|
+
pipeline = _build_pipeline(lanes=lanes, handlers_module=handlers_module)
|
|
87
|
+
workers_by_stage = parse_workers_arg(
|
|
88
|
+
workers,
|
|
89
|
+
pipeline.step_names(),
|
|
90
|
+
default=2,
|
|
91
|
+
)
|
|
92
|
+
expected = pipeline.expected_job_count(repeats)
|
|
93
|
+
event_sink = _build_event_sink(sink)
|
|
94
|
+
|
|
95
|
+
manifest = setup_run_queues(
|
|
96
|
+
pipeline=pipeline,
|
|
97
|
+
run_id=resolved_run_id,
|
|
98
|
+
workers_by_stage=workers_by_stage,
|
|
99
|
+
expected_jobs=expected,
|
|
100
|
+
)
|
|
101
|
+
jobs = pipeline.make_seed_jobs(run_id=resolved_run_id, repeats=repeats)
|
|
102
|
+
seed_manifest_jobs(manifest, jobs)
|
|
103
|
+
|
|
104
|
+
typer.echo(f"run_id={resolved_run_id}")
|
|
105
|
+
typer.echo(f"manifest={manifest_path(resolved_run_id)}")
|
|
106
|
+
typer.echo(f"expected_jobs={expected} sink={sink}")
|
|
107
|
+
|
|
108
|
+
run_in_process(
|
|
109
|
+
manifest=manifest,
|
|
110
|
+
pipeline=pipeline,
|
|
111
|
+
workers_by_stage=workers_by_stage,
|
|
112
|
+
event_sink=event_sink,
|
|
113
|
+
completion_timeout=completion_timeout,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
events = filter_run_events(
|
|
117
|
+
event_sink.read_by_run_id(resolved_run_id),
|
|
118
|
+
resolved_run_id,
|
|
119
|
+
)
|
|
120
|
+
event_sink.close()
|
|
121
|
+
terminals = [
|
|
122
|
+
event for event in events if event.event == EventKind.TERMINAL
|
|
123
|
+
]
|
|
124
|
+
typer.echo(f"events={len(events)} terminals={len(terminals)}")
|
|
125
|
+
if terminals:
|
|
126
|
+
sample = terminals[0].payload
|
|
127
|
+
typer.echo(
|
|
128
|
+
"sample_terminal="
|
|
129
|
+
f"lane={sample.get('lane')} "
|
|
130
|
+
f"transform={sample.get('step_outputs', {}).get('transform')}",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if len(terminals) != expected:
|
|
134
|
+
typer.echo("Terminal count mismatch.", err=True)
|
|
135
|
+
raise typer.Exit(code=1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def run() -> None:
|
|
139
|
+
app()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
run()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from dr_queues.events.mongo import MongoEventSink
|
|
13
|
+
from dr_queues.manifest import (
|
|
14
|
+
load_run_manifest,
|
|
15
|
+
manifest_path,
|
|
16
|
+
read_pid,
|
|
17
|
+
remove_pid,
|
|
18
|
+
stage_pid_path,
|
|
19
|
+
write_pid,
|
|
20
|
+
)
|
|
21
|
+
from dr_queues.pipeline.workers import WorkerPool
|
|
22
|
+
from dr_queues.workflow.pipeline import Pipeline
|
|
23
|
+
from dr_queues.workflow.registry import HandlerRegistry
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(add_completion=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _stop_pid(pid: int, *, timeout: float = 30.0) -> None:
|
|
29
|
+
try:
|
|
30
|
+
os.kill(pid, signal.SIGTERM)
|
|
31
|
+
except ProcessLookupError:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
deadline = time.monotonic() + timeout
|
|
35
|
+
while time.monotonic() < deadline:
|
|
36
|
+
try:
|
|
37
|
+
os.kill(pid, 0)
|
|
38
|
+
except ProcessLookupError:
|
|
39
|
+
return
|
|
40
|
+
time.sleep(0.2)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_registry(module_path: str) -> HandlerRegistry:
|
|
44
|
+
module = importlib.import_module(module_path)
|
|
45
|
+
registry = getattr(module, "registry", None)
|
|
46
|
+
if registry is None:
|
|
47
|
+
msg = f"Module {module_path!r} has no registry attribute."
|
|
48
|
+
raise typer.BadParameter(msg)
|
|
49
|
+
return registry
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def main(
|
|
54
|
+
run_id: str | None = typer.Option(None, "--run-id"),
|
|
55
|
+
stage: str = typer.Option(..., "--stage"),
|
|
56
|
+
workers: int = typer.Option(..., "--workers"),
|
|
57
|
+
manifest: Path | None = typer.Option(None, "--manifest"),
|
|
58
|
+
handlers_module: str = typer.Option(
|
|
59
|
+
"dr_queues.demo_handlers",
|
|
60
|
+
"--handlers-module",
|
|
61
|
+
),
|
|
62
|
+
replace: bool = typer.Option(False, "--replace"),
|
|
63
|
+
) -> None:
|
|
64
|
+
resolved_manifest_path = manifest or (
|
|
65
|
+
manifest_path(run_id) if run_id else None
|
|
66
|
+
)
|
|
67
|
+
if resolved_manifest_path is None or not resolved_manifest_path.exists():
|
|
68
|
+
typer.echo(
|
|
69
|
+
"Manifest not found. Pass --manifest or --run-id.",
|
|
70
|
+
err=True,
|
|
71
|
+
)
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
|
|
74
|
+
run_manifest = load_run_manifest(resolved_manifest_path)
|
|
75
|
+
stage_entry = next(
|
|
76
|
+
(item for item in run_manifest.stages if item.name == stage),
|
|
77
|
+
None,
|
|
78
|
+
)
|
|
79
|
+
if stage_entry is None:
|
|
80
|
+
typer.echo(f"Unknown stage {stage!r} in manifest.", err=True)
|
|
81
|
+
raise typer.Exit(code=1)
|
|
82
|
+
|
|
83
|
+
pid_path = stage_pid_path(run_manifest.run_id, stage)
|
|
84
|
+
if replace:
|
|
85
|
+
existing_pid = read_pid(pid_path)
|
|
86
|
+
if existing_pid is not None:
|
|
87
|
+
typer.echo(f"Stopping existing worker pid={existing_pid}...")
|
|
88
|
+
_stop_pid(existing_pid)
|
|
89
|
+
remove_pid(pid_path)
|
|
90
|
+
|
|
91
|
+
registry = _load_registry(handlers_module)
|
|
92
|
+
pipeline = Pipeline(run_manifest.pipeline_definition, registry)
|
|
93
|
+
handler = pipeline.make_handler(stage_entry.step_index)
|
|
94
|
+
event_sink = MongoEventSink()
|
|
95
|
+
pool = WorkerPool(
|
|
96
|
+
input_queue=stage_entry.input_queue,
|
|
97
|
+
output_queue=stage_entry.output_queue,
|
|
98
|
+
handler=handler,
|
|
99
|
+
event_sink=event_sink,
|
|
100
|
+
workers=workers,
|
|
101
|
+
stage_name=stage_entry.name,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
write_pid(pid_path, os.getpid())
|
|
105
|
+
typer.echo(
|
|
106
|
+
f"stage={stage} workers={workers} input={stage_entry.input_queue}",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _shutdown(_signum: int, _frame: object) -> None:
|
|
110
|
+
typer.echo(f"Stopping stage {stage}...")
|
|
111
|
+
pool.stop()
|
|
112
|
+
|
|
113
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
114
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
115
|
+
|
|
116
|
+
pool.start()
|
|
117
|
+
try:
|
|
118
|
+
while not pool._stop.is_set():
|
|
119
|
+
time.sleep(0.5)
|
|
120
|
+
finally:
|
|
121
|
+
pool.stop()
|
|
122
|
+
pool.join(timeout=5)
|
|
123
|
+
event_sink.close()
|
|
124
|
+
remove_pid(pid_path)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run() -> None:
|
|
128
|
+
app()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
run()
|