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 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()
@@ -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,3 @@
1
+ from dr_queues.analysis.filter import filter_run_event_dicts, filter_run_events
2
+
3
+ __all__ = ["filter_run_event_dicts", "filter_run_events"]
@@ -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,6 @@
1
+ from dr_queues.cli.commands import (
2
+ STAGE_WORKER_ENTRYPOINT,
3
+ stage_worker_command_prefix,
4
+ )
5
+
6
+ __all__ = ["STAGE_WORKER_ENTRYPOINT", "stage_worker_command_prefix"]
@@ -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()