hexaqueue-worker 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.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: hexaqueue-worker
3
+ Version: 0.1.0
4
+ Summary: Hexaqueue Worker - Lightweight Compute Node Execution Daemon
5
+ Author: Richard West
6
+ Author-email: Richard West <dopplereffect.us@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ Requires-Dist: hexaqueue-core
9
+ Requires-Dist: hexastack-auth>=0.3.0
10
+ Requires-Dist: hexastack-core>=0.3.0
11
+ Requires-Dist: hexastack-grpc>=0.3.0
12
+ Requires-Dist: hexastack-logging>=0.3.0
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # 🐝 `hexaqueue-worker`
17
+
18
+ > Lightweight compute node execution daemon with GPU isolation and real-time streaming.
19
+
20
+ [![PyPI: hexaqueue-worker](https://img.shields.io/pypi/v/hexaqueue-worker.svg)](https://pypi.org/project/hexaqueue-worker/)
21
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
22
+ [![Coverage](https://codecov.io/github/TheTrueSCU/hexaqueue/graph/badge.svg?component=hexaqueue_worker)](https://codecov.io/github/TheTrueSCU/hexaqueue)
23
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
24
+
25
+ ---
26
+
27
+ ## 🏗️ Architecture & Dependencies
28
+
29
+ ```mermaid
30
+ graph TD
31
+ subgraph S1["hexaqueue-worker"]
32
+ RUNNER["Execution Runtimes (cgroups v2, Docker/Podman, Apptainer)"]
33
+ GPU["GPU Masking (NVML & CUDA_VISIBLE_DEVICES)"]
34
+ STREAM["Log & Telemetry gRPC Streaming"]
35
+ end
36
+
37
+ subgraph S2["Internal & Hexastack Dependencies"]
38
+ HQ_CORE["hexaqueue-core"]
39
+ HS_AUTH["hexastack-auth (SPIFFE mTLS)"]
40
+ HS_CORE["hexastack-core"]
41
+ HS_GRPC["hexastack-grpc (Bidirectional Streams)"]
42
+ HS_LOG["hexastack-logging (structlog)"]
43
+ end
44
+
45
+ S1 --> HQ_CORE
46
+ S1 --> HS_AUTH
47
+ S1 --> HS_CORE
48
+ S1 --> HS_GRPC
49
+ S1 --> HS_LOG
50
+ ```
@@ -0,0 +1,35 @@
1
+ # 🐝 `hexaqueue-worker`
2
+
3
+ > Lightweight compute node execution daemon with GPU isolation and real-time streaming.
4
+
5
+ [![PyPI: hexaqueue-worker](https://img.shields.io/pypi/v/hexaqueue-worker.svg)](https://pypi.org/project/hexaqueue-worker/)
6
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
7
+ [![Coverage](https://codecov.io/github/TheTrueSCU/hexaqueue/graph/badge.svg?component=hexaqueue_worker)](https://codecov.io/github/TheTrueSCU/hexaqueue)
8
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
9
+
10
+ ---
11
+
12
+ ## 🏗️ Architecture & Dependencies
13
+
14
+ ```mermaid
15
+ graph TD
16
+ subgraph S1["hexaqueue-worker"]
17
+ RUNNER["Execution Runtimes (cgroups v2, Docker/Podman, Apptainer)"]
18
+ GPU["GPU Masking (NVML & CUDA_VISIBLE_DEVICES)"]
19
+ STREAM["Log & Telemetry gRPC Streaming"]
20
+ end
21
+
22
+ subgraph S2["Internal & Hexastack Dependencies"]
23
+ HQ_CORE["hexaqueue-core"]
24
+ HS_AUTH["hexastack-auth (SPIFFE mTLS)"]
25
+ HS_CORE["hexastack-core"]
26
+ HS_GRPC["hexastack-grpc (Bidirectional Streams)"]
27
+ HS_LOG["hexastack-logging (structlog)"]
28
+ end
29
+
30
+ S1 --> HQ_CORE
31
+ S1 --> HS_AUTH
32
+ S1 --> HS_CORE
33
+ S1 --> HS_GRPC
34
+ S1 --> HS_LOG
35
+ ```
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "hexaqueue-worker"
3
+ version = "0.1.0"
4
+ description = "Hexaqueue Worker - Lightweight Compute Node Execution Daemon"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "hexaqueue-core",
10
+ "hexastack-auth>=0.3.0",
11
+ "hexastack-core>=0.3.0",
12
+ "hexastack-grpc>=0.3.0",
13
+ "hexastack-logging>=0.3.0",
14
+ ]
15
+
16
+ [[project.authors]]
17
+ name = "Richard West"
18
+ email = "dopplereffect.us@gmail.com"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.12.3,<0.13.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.uv.sources.hexaqueue-core]
25
+ workspace = true
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "hexaqueue-worker"
3
+ version = "0.1.0"
4
+ description = "Hexaqueue Worker - Lightweight Compute Node Execution Daemon"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ authors = [
8
+ { name = "Richard West", email = "dopplereffect.us@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "hexaqueue-core",
13
+ "hexastack-auth>=0.3.0",
14
+ "hexastack-core>=0.3.0",
15
+ "hexastack-grpc>=0.3.0",
16
+ "hexastack-logging>=0.3.0",
17
+ ]
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.12.3,<0.13.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [tool.uv.sources]
24
+ hexaqueue-core = { workspace = true }
@@ -0,0 +1,14 @@
1
+ """Hexaqueue Worker - Lightweight Compute Node Execution Daemon."""
2
+
3
+ from hexaqueue_worker.adapters.local import LocalSubprocessWorker
4
+ from hexaqueue_worker.domain.models import WorkerConfig, WorkerMetrics
5
+ from hexaqueue_worker.infra.bootstrap import WorkerBootstrapper
6
+ from hexaqueue_worker.ports.worker import WorkerDaemonPort
7
+
8
+ __all__ = [
9
+ "LocalSubprocessWorker",
10
+ "WorkerBootstrapper",
11
+ "WorkerConfig",
12
+ "WorkerDaemonPort",
13
+ "WorkerMetrics",
14
+ ]
@@ -0,0 +1,7 @@
1
+ """Adapters package for hexaqueue-worker."""
2
+
3
+ from hexaqueue_worker.adapters.local import LocalSubprocessWorker
4
+
5
+ __all__ = [
6
+ "LocalSubprocessWorker",
7
+ ]
@@ -0,0 +1,179 @@
1
+ """Local subprocess compute node worker daemon adapter.
2
+
3
+ Notes/Architectural Intent:
4
+ Polls ready jobs from JobQueuePort, allocates isolated scratch storage via StorageVolumePort,
5
+ runs commands via ExecutionRuntimePort, and updates terminal outcomes via SchedulerControllerPort.
6
+ Ensures zero cross-job pollution by cleaning up ephemeral scratch allocations in a finally block.
7
+ """
8
+
9
+ import asyncio
10
+ import contextlib
11
+ from typing import Any
12
+
13
+ from hexaqueue_core.adapters.runtime.local import LocalSubprocessExecutionRuntimeAdapter
14
+ from hexaqueue_core.adapters.storage.local import LocalDiskStorageVolumeAdapter
15
+ from hexaqueue_core.domain.job import JobSpec
16
+ from hexaqueue_core.ports.logging import LogStreamPort
17
+ from hexaqueue_core.ports.queue import JobQueuePort
18
+ from hexaqueue_core.ports.runtime import ExecutionRuntimePort, ProcessExecutionResult
19
+ from hexaqueue_core.ports.storage import StorageVolumePort, VolumeAllocation
20
+ from hexaqueue_worker.domain.models import WorkerConfig, WorkerMetrics
21
+ from hexaqueue_worker.ports.worker import WorkerDaemonPort
22
+
23
+
24
+ class LocalSubprocessWorker(WorkerDaemonPort):
25
+ """Local subprocess execution worker daemon.
26
+
27
+ Args:
28
+ queue: JobQueuePort to dequeue ready jobs from.
29
+ controller: Optional SchedulerControllerPort to update job terminal outcomes.
30
+ runtime: Optional ExecutionRuntimePort (defaults to LocalSubprocessExecutionRuntimeAdapter).
31
+ storage: Optional StorageVolumePort (defaults to LocalStorageVolumeAdapter).
32
+ log_port: Optional LogStreamPort for streaming stdout/stderr.
33
+ config: Optional WorkerConfig for concurrency and polling parameters.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ queue: JobQueuePort,
39
+ controller: Any | None = None,
40
+ runtime: ExecutionRuntimePort | None = None,
41
+ storage: StorageVolumePort | None = None,
42
+ log_port: LogStreamPort | None = None,
43
+ config: WorkerConfig | None = None,
44
+ ) -> None:
45
+ self._queue = queue
46
+ self._controller = controller
47
+ self._log_port = log_port
48
+ self._runtime = runtime or LocalSubprocessExecutionRuntimeAdapter(
49
+ log_port=self._log_port
50
+ )
51
+ self._storage = storage or LocalDiskStorageVolumeAdapter()
52
+ self._config = config or WorkerConfig()
53
+
54
+ self._semaphore = asyncio.Semaphore(self._config.concurrency)
55
+ self._active_jobs: set[str] = set()
56
+ self._running = False
57
+ self._worker_tasks: set[asyncio.Task[None]] = set()
58
+ self._loop_task: asyncio.Task[None] | None = None
59
+
60
+ self._total_executed = 0
61
+ self._total_completed = 0
62
+ self._total_failed = 0
63
+ self._lock = asyncio.Lock()
64
+
65
+ async def start(self) -> None:
66
+ """Start worker polling loop in background task."""
67
+ async with self._lock:
68
+ if self._running:
69
+ return
70
+ self._running = True
71
+ self._loop_task = asyncio.create_task(self._poll_loop())
72
+
73
+ async def stop(self) -> None:
74
+ """Gracefully stop worker loop and await in-flight tasks."""
75
+ async with self._lock:
76
+ if not self._running:
77
+ return
78
+ self._running = False
79
+
80
+ if self._loop_task:
81
+ self._loop_task.cancel()
82
+ with contextlib.suppress(asyncio.CancelledError):
83
+ await self._loop_task
84
+ self._loop_task = None
85
+
86
+ if self._worker_tasks:
87
+ await asyncio.gather(*self._worker_tasks, return_exceptions=True)
88
+ self._worker_tasks.clear()
89
+
90
+ async def _poll_loop(self) -> None:
91
+ """Main queue polling loop."""
92
+ while self._running:
93
+ try:
94
+ # Wait for concurrency slot
95
+ await self._semaphore.acquire()
96
+ job = await self._queue.dequeue(
97
+ timeout_seconds=self._config.poll_interval_seconds
98
+ )
99
+ if job is None:
100
+ self._semaphore.release()
101
+ await asyncio.sleep(self._config.poll_interval_seconds)
102
+ continue
103
+
104
+ task = asyncio.create_task(self._process_job_slot(job))
105
+ self._worker_tasks.add(task)
106
+ task.add_done_callback(self._worker_tasks.discard)
107
+ except asyncio.CancelledError:
108
+ break
109
+ except Exception:
110
+ await asyncio.sleep(self._config.poll_interval_seconds)
111
+
112
+ async def _process_job_slot(self, job: JobSpec) -> None:
113
+ """Process job with acquired semaphore slot."""
114
+ try:
115
+ await self.execute_job(job)
116
+ finally:
117
+ self._semaphore.release()
118
+
119
+ async def execute_job(self, job: JobSpec) -> ProcessExecutionResult:
120
+ """Execute a single job within isolated scratch storage and report status."""
121
+ async with self._lock:
122
+ self._active_jobs.add(job.id)
123
+ self._total_executed += 1
124
+
125
+ scratch_vol: VolumeAllocation | None = None
126
+ try:
127
+ # 1. Allocate isolated scratch workspace
128
+ scratch_vol = await self._storage.allocate_scratch(
129
+ job_id=job.id,
130
+ size_mb=job.resources.scratch_mb,
131
+ base_dir=self._config.scratch_base_dir,
132
+ )
133
+
134
+ # 2. Execute process via runtime
135
+ result = await self._runtime.execute(
136
+ job=job,
137
+ scratch_volume=scratch_vol,
138
+ environment=job.env,
139
+ )
140
+
141
+ # 3. Notify controller if configured
142
+ if self._controller:
143
+ await self._controller.update_job_outcome(
144
+ job_id=job.id,
145
+ outcome=result.outcome,
146
+ reason=result.error_message,
147
+ )
148
+
149
+ async with self._lock:
150
+ if result.exit_code == 0:
151
+ self._total_completed += 1
152
+ else:
153
+ self._total_failed += 1
154
+
155
+ return result
156
+ finally:
157
+ # 4. Clean up scratch storage
158
+ if scratch_vol and scratch_vol.is_ephemeral:
159
+ await self._storage.cleanup_scratch(scratch_vol.volume_id)
160
+
161
+ async with self._lock:
162
+ self._active_jobs.discard(job.id)
163
+
164
+ async def get_metrics(self) -> WorkerMetrics:
165
+ """Retrieve real-time operational worker metrics."""
166
+ async with self._lock:
167
+ return WorkerMetrics(
168
+ worker_id=self._config.worker_id,
169
+ active_jobs=len(self._active_jobs),
170
+ total_executed=self._total_executed,
171
+ total_completed=self._total_completed,
172
+ total_failed=self._total_failed,
173
+ is_running=self._running,
174
+ )
175
+
176
+
177
+ __all__ = [
178
+ "LocalSubprocessWorker",
179
+ ]
@@ -0,0 +1,8 @@
1
+ """Domain package for hexaqueue-worker."""
2
+
3
+ from hexaqueue_worker.domain.models import WorkerConfig, WorkerMetrics
4
+
5
+ __all__ = [
6
+ "WorkerConfig",
7
+ "WorkerMetrics",
8
+ ]
@@ -0,0 +1,70 @@
1
+ """Domain models for worker configuration and metrics.
2
+
3
+ Notes/Architectural Intent:
4
+ Defines configuration structures and status metrics for worker daemon operations,
5
+ specifying concurrency thresholds, polling intervals, and scratch volume base paths.
6
+ """
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+
11
+ class WorkerConfig(BaseModel):
12
+ """Configuration options for a compute worker daemon instance.
13
+
14
+ Args:
15
+ worker_id: Unique worker identifier.
16
+ concurrency: Maximum number of concurrent jobs to execute.
17
+ poll_interval_seconds: Polling interval when waiting for queued jobs.
18
+ scratch_base_dir: Optional base directory for isolating scratch workspaces.
19
+ grace_period_seconds: Timeout to wait after SIGTERM before SIGKILL.
20
+ """
21
+
22
+ model_config = ConfigDict(frozen=True, extra="forbid")
23
+
24
+ concurrency: int = Field(
25
+ default=4, ge=1, description="Maximum concurrent job execution limit"
26
+ )
27
+ grace_period_seconds: int = Field(
28
+ default=15, ge=1, description="Grace period before SIGKILL"
29
+ )
30
+ poll_interval_seconds: float = Field(
31
+ default=0.1, gt=0.0, description="Queue polling interval in seconds"
32
+ )
33
+ scratch_base_dir: str | None = Field(
34
+ default=None, description="Base directory for scratch storage allocations"
35
+ )
36
+ worker_id: str = Field(
37
+ default="local-worker", description="Unique worker instance identifier"
38
+ )
39
+
40
+
41
+ class WorkerMetrics(BaseModel):
42
+ """Real-time operational metrics of a compute worker.
43
+
44
+ Args:
45
+ worker_id: Unique worker identifier.
46
+ active_jobs: Count of jobs currently executing.
47
+ total_executed: Total count of jobs executed since startup.
48
+ total_failed: Total count of failed jobs.
49
+ total_completed: Total count of successfully completed jobs.
50
+ is_running: Whether the worker execution loop is active.
51
+ """
52
+
53
+ model_config = ConfigDict(frozen=True, extra="forbid")
54
+
55
+ active_jobs: int = Field(ge=0, description="Number of currently executing jobs")
56
+ is_running: bool = Field(description="Whether the worker daemon is running")
57
+ total_completed: int = Field(
58
+ default=0, ge=0, description="Total completed jobs count"
59
+ )
60
+ total_executed: int = Field(
61
+ default=0, ge=0, description="Total executed jobs count"
62
+ )
63
+ total_failed: int = Field(default=0, ge=0, description="Total failed jobs count")
64
+ worker_id: str = Field(description="Worker instance identifier")
65
+
66
+
67
+ __all__ = [
68
+ "WorkerConfig",
69
+ "WorkerMetrics",
70
+ ]
@@ -0,0 +1,7 @@
1
+ """Infra package for hexaqueue-worker."""
2
+
3
+ from hexaqueue_worker.infra.bootstrap import WorkerBootstrapper
4
+
5
+ __all__ = [
6
+ "WorkerBootstrapper",
7
+ ]
@@ -0,0 +1,23 @@
1
+ """Bootstrap lifecycle handler for hexaqueue-worker."""
2
+
3
+ from hexaqueue_core.infra.bootstrap import HexaqueueBootstrapContext
4
+ from hexaqueue_core.infra.registries import HexaqueueConfigRegistry
5
+ from hexaqueue_core.ports.bootstrap import BootstrapperPort
6
+
7
+
8
+ class WorkerBootstrapper(BootstrapperPort):
9
+ """Bootstrapper for hexaqueue-worker services and registries."""
10
+
11
+ name: str = "worker"
12
+ order: int = 40
13
+
14
+ def register_config(self, registry: HexaqueueConfigRegistry) -> None:
15
+ """Register worker configuration models."""
16
+
17
+ def configure(self, context: HexaqueueBootstrapContext) -> None:
18
+ """Configure worker services in application DI container."""
19
+
20
+
21
+ __all__ = [
22
+ "WorkerBootstrapper",
23
+ ]
@@ -0,0 +1,7 @@
1
+ """Ports package for hexaqueue-worker."""
2
+
3
+ from hexaqueue_worker.ports.worker import WorkerDaemonPort
4
+
5
+ __all__ = [
6
+ "WorkerDaemonPort",
7
+ ]
@@ -0,0 +1,48 @@
1
+ """Worker daemon port interface.
2
+
3
+ Notes/Architectural Intent:
4
+ Defines the contract for worker daemons managing task polling, isolated workspace provisioning,
5
+ command execution via ExecutionRuntimePort, and outcome notification to SchedulerControllerPort.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+
10
+ from hexaqueue_core.domain.job import JobSpec
11
+ from hexaqueue_core.ports.runtime import ProcessExecutionResult
12
+ from hexaqueue_worker.domain.models import WorkerMetrics
13
+
14
+
15
+ class WorkerDaemonPort(ABC):
16
+ """Abstract port interface for worker compute daemons."""
17
+
18
+ @abstractmethod
19
+ async def start(self) -> None:
20
+ """Start worker loop pulling and executing jobs."""
21
+
22
+ @abstractmethod
23
+ async def stop(self) -> None:
24
+ """Gracefully stop worker loop and await in-flight tasks."""
25
+
26
+ @abstractmethod
27
+ async def execute_job(self, job: JobSpec) -> ProcessExecutionResult:
28
+ """Execute a single job within isolated scratch storage and report status.
29
+
30
+ Args:
31
+ job: JobSpec to execute.
32
+
33
+ Returns:
34
+ ProcessExecutionResult summarizing execution outcome.
35
+ """
36
+
37
+ @abstractmethod
38
+ async def get_metrics(self) -> WorkerMetrics:
39
+ """Retrieve real-time operational worker metrics.
40
+
41
+ Returns:
42
+ WorkerMetrics instance.
43
+ """
44
+
45
+
46
+ __all__ = [
47
+ "WorkerDaemonPort",
48
+ ]