strata-pool 0.7.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.
@@ -0,0 +1,31 @@
1
+ """Worker pool for dispatching Strata jobs to ephemeral machines."""
2
+
3
+ from strata_pool.backend import Backend, ProvisionedWorker
4
+ from strata_pool.backends import DockerBackend, RunPodBackend
5
+ from strata_pool.pool import Pool
6
+ from strata_pool.store import PoolStore
7
+ from strata_pool.types import (
8
+ Job,
9
+ JobState,
10
+ MachineType,
11
+ UsageEvent,
12
+ Worker,
13
+ WorkerState,
14
+ )
15
+
16
+ __all__ = [
17
+ "Backend",
18
+ "DockerBackend",
19
+ "Job",
20
+ "JobState",
21
+ "MachineType",
22
+ "Pool",
23
+ "PoolStore",
24
+ "ProvisionedWorker",
25
+ "RunPodBackend",
26
+ "UsageEvent",
27
+ "Worker",
28
+ "WorkerState",
29
+ ]
30
+
31
+ __version__ = "0.1.0"
strata_pool/api.py ADDED
@@ -0,0 +1,217 @@
1
+ """HTTP surface, for running the pool as a service.
2
+
3
+ Needs the `server` extra. The pool is usable as a library without it, and the
4
+ proxy that composes it may well bring its own framework, so a web server is
5
+ not something `import strata_pool` should pull in.
6
+
7
+ Job payloads and results are opaque bytes, so they travel as raw request and
8
+ response bodies rather than being wedged into JSON. Everything else — status,
9
+ fleet, usage — is JSON.
10
+
11
+ The caller is trusted for tenant identity. It presents the pool's API token
12
+ and asserts a tenant in a header; the pool does not authenticate end users and
13
+ has no idea who they are. That is the same trusted-proxy model the Strata
14
+ server uses, and it means the pool must never be reachable from anywhere but
15
+ the proxy.
16
+ """
17
+
18
+ import logging
19
+ from dataclasses import asdict
20
+ from typing import Annotated
21
+
22
+ from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response
23
+ from fastapi.responses import JSONResponse
24
+
25
+ from strata_pool.pool import Pool
26
+ from strata_pool.types import Job, JobState, UsageEvent, Worker
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ TENANT_HEADER = "X-Strata-Tenant"
31
+
32
+
33
+ def _job_json(job: Job) -> dict:
34
+ """A job without its payload or result, which are bytes and often large."""
35
+ fields = asdict(job)
36
+ fields.pop("payload")
37
+ fields.pop("result")
38
+ fields["has_result"] = job.result is not None
39
+ return fields
40
+
41
+
42
+ def _worker_json(worker: Worker) -> dict:
43
+ """A machine without its credential.
44
+
45
+ Built by hand rather than from asdict: `repr=False` keeps the token out of
46
+ logs but not out of a dict, and this response is the one place it would
47
+ otherwise be handed to whoever asked.
48
+ """
49
+ fields = asdict(worker)
50
+ fields.pop("auth_token")
51
+ return fields
52
+
53
+
54
+ def _usage_json(event: UsageEvent) -> dict:
55
+ return asdict(event)
56
+
57
+
58
+ def create_app(
59
+ pool: Pool,
60
+ *,
61
+ api_token: str | None = None,
62
+ scaler_interval_seconds: float = 10.0,
63
+ ) -> FastAPI:
64
+ """Build the pool's HTTP app.
65
+
66
+ Args:
67
+ api_token: Bearer token every route except `/health` requires. None
68
+ disables the check, which is a local-development choice: an open
69
+ submit endpoint runs arbitrary payloads on machines you pay for.
70
+ scaler_interval_seconds: How often idle machines are reaped. The app
71
+ starts the scaler itself, so a deployment cannot forget to.
72
+ """
73
+ if api_token is None:
74
+ logger.warning("pool API starting with no token; anyone who can reach it can run jobs")
75
+
76
+ async def require_token(authorization: Annotated[str | None, Header()] = None) -> None:
77
+ if api_token is None:
78
+ return
79
+ if authorization != f"Bearer {api_token}":
80
+ raise HTTPException(status_code=401, detail="invalid or missing API token")
81
+
82
+ async def tenant(request: Request) -> str:
83
+ value = request.headers.get(TENANT_HEADER)
84
+ if not value:
85
+ raise HTTPException(status_code=400, detail=f"{TENANT_HEADER} is required")
86
+ return value
87
+
88
+ async def lifespan(app: FastAPI):
89
+ # Reconcile before serving: machines from a previous process are
90
+ # either still reachable or still billing.
91
+ await pool.recover()
92
+ pool.start_scaler(scaler_interval_seconds)
93
+ yield
94
+ await pool.aclose()
95
+
96
+ app = FastAPI(title="strata-pool", lifespan=lifespan)
97
+ guard = [Depends(require_token)]
98
+
99
+ @app.get("/health")
100
+ async def health() -> dict:
101
+ """Deliberately outside the token check, for load balancers."""
102
+ workers = pool.store.list_workers()
103
+ counts: dict[str, int] = {}
104
+ for worker in workers:
105
+ counts[worker.state.value] = counts.get(worker.state.value, 0) + 1
106
+ return {"status": "ok", "workers": counts, "machine_types": list(pool.machine_types)}
107
+
108
+ @app.post("/v1/jobs", status_code=202, dependencies=guard)
109
+ async def submit_job(
110
+ request: Request,
111
+ machine_type: str,
112
+ tenant_id: Annotated[str, Depends(tenant)],
113
+ priority: int = 0,
114
+ session_id: str | None = None,
115
+ timeout_seconds: float | None = None,
116
+ ) -> JSONResponse:
117
+ """Queue a job. The request body is the payload, verbatim."""
118
+ job = await _submit(
119
+ pool,
120
+ tenant_id=tenant_id,
121
+ machine_type=machine_type,
122
+ payload=await request.body(),
123
+ priority=priority,
124
+ session_id=session_id,
125
+ timeout_seconds=timeout_seconds,
126
+ )
127
+ return JSONResponse(_job_json(job), status_code=202)
128
+
129
+ @app.post("/v1/jobs/sync", dependencies=guard)
130
+ async def submit_and_wait(
131
+ request: Request,
132
+ machine_type: str,
133
+ tenant_id: Annotated[str, Depends(tenant)],
134
+ priority: int = 0,
135
+ session_id: str | None = None,
136
+ timeout_seconds: float | None = None,
137
+ wait_seconds: float = 300.0,
138
+ ) -> Response:
139
+ """Queue a job and block until it finishes.
140
+
141
+ What the notebook's executor protocol wants, since it expects one
142
+ synchronous response. `wait_seconds` bounds how long the caller waits,
143
+ not how long the job may run — a job that outlives it keeps going and
144
+ can be collected by ID.
145
+ """
146
+ job = await _submit(
147
+ pool,
148
+ tenant_id=tenant_id,
149
+ machine_type=machine_type,
150
+ payload=await request.body(),
151
+ priority=priority,
152
+ session_id=session_id,
153
+ timeout_seconds=timeout_seconds,
154
+ )
155
+ try:
156
+ done = await pool.wait(job.id, timeout=wait_seconds)
157
+ except TimeoutError:
158
+ # 202: it is still running, and the ID is how you find it. Re-read
159
+ # for the freshest state, falling back to the submitted snapshot
160
+ # rather than pretending a row we just wrote could be missing.
161
+ latest = pool.store.get_job(job.id) or job
162
+ return JSONResponse(_job_json(latest), status_code=202)
163
+ return _terminal_response(done)
164
+
165
+ @app.get("/v1/jobs/{job_id}", dependencies=guard)
166
+ async def get_job(job_id: str) -> dict:
167
+ job = pool.store.get_job(job_id)
168
+ if job is None:
169
+ raise HTTPException(status_code=404, detail=f"no such job: {job_id}")
170
+ return _job_json(job)
171
+
172
+ @app.get("/v1/jobs/{job_id}/result", dependencies=guard)
173
+ async def get_job_result(job_id: str) -> Response:
174
+ """The raw result bytes, once there are any."""
175
+ job = pool.store.get_job(job_id)
176
+ if job is None:
177
+ raise HTTPException(status_code=404, detail=f"no such job: {job_id}")
178
+ if job.state not in (JobState.COMPLETED, JobState.FAILED, JobState.TIMED_OUT):
179
+ raise HTTPException(status_code=409, detail=f"job is {job.state.value}")
180
+ return _terminal_response(job)
181
+
182
+ @app.get("/v1/machine-types", dependencies=guard)
183
+ async def list_machine_types() -> list[dict]:
184
+ """What a caller may ask for. The catalogue an annotation resolves against."""
185
+ return [asdict(spec) for spec in pool.machine_types.values()]
186
+
187
+ @app.get("/v1/workers", dependencies=guard)
188
+ async def list_workers() -> list[dict]:
189
+ return [_worker_json(worker) for worker in pool.store.list_workers()]
190
+
191
+ @app.get("/v1/usage", dependencies=guard)
192
+ async def list_usage(tenant_id: str | None = Query(default=None)) -> list[dict]:
193
+ """The billing feed. One event per terminal job, monotonic duration."""
194
+ return [_usage_json(event) for event in pool.store.list_usage(tenant_id)]
195
+
196
+ return app
197
+
198
+
199
+ async def _submit(pool: Pool, **kwargs) -> Job:
200
+ try:
201
+ return await pool.submit(**kwargs)
202
+ except ValueError as exc:
203
+ # An unknown machine type is the caller's mistake, not a pool failure.
204
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
205
+
206
+
207
+ def _terminal_response(job: Job) -> Response:
208
+ """Map a finished job onto a status code.
209
+
210
+ A failure on the worker is reported as a failure of the job, not of the
211
+ pool: the caller needs to tell "your code raised" from "we could not run
212
+ it", and a 500 would blur the two.
213
+ """
214
+ if job.state is JobState.COMPLETED:
215
+ return Response(content=job.result or b"", media_type="application/octet-stream")
216
+ status = 504 if job.state is JobState.TIMED_OUT else 502
217
+ return JSONResponse({"state": job.state.value, "error": job.error}, status_code=status)
strata_pool/backend.py ADDED
@@ -0,0 +1,56 @@
1
+ """The seam between the pool and whatever actually runs machines."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Protocol
5
+
6
+ from strata_pool.types import MachineType
7
+
8
+
9
+ @dataclass
10
+ class ProvisionedWorker:
11
+ """What a backend hands back from `start()`."""
12
+
13
+ backend_id: str
14
+ """Cloud-specific resource ID, used for stop()."""
15
+
16
+ endpoint: str
17
+ """HTTP base URL where the worker will accept jobs.
18
+
19
+ Required at start time. A backend that only learns the address later
20
+ should block until it knows it: the pool has no other way to health-check
21
+ a booting machine, and a worker it cannot reach is a worker it cannot
22
+ stop billing for.
23
+ """
24
+
25
+ region: str | None = None
26
+ metadata: dict[str, str] = field(default_factory=dict)
27
+
28
+
29
+ class Backend(Protocol):
30
+ """A cloud provider or local runtime that can start and stop machines."""
31
+
32
+ name: str
33
+
34
+ async def start(
35
+ self,
36
+ spec: MachineType,
37
+ env: dict[str, str] | None = None,
38
+ ) -> ProvisionedWorker:
39
+ """Provision and start a worker.
40
+
41
+ Takes the whole machine type rather than a name and an image: a
42
+ backend needs the resource limits too, and each will grow its own
43
+ provider-specific fields. A backend ignores what it cannot express.
44
+
45
+ Returns once the machine is booting; it does not need to be ready.
46
+ The pool polls `health()` until it is.
47
+ """
48
+ ...
49
+
50
+ async def stop(self, backend_id: str) -> None:
51
+ """Stop and deallocate a worker. Must be idempotent."""
52
+ ...
53
+
54
+ async def health(self, endpoint: str) -> bool:
55
+ """Report whether a worker is ready to accept jobs."""
56
+ ...
@@ -0,0 +1,6 @@
1
+ """Backends that provision machines."""
2
+
3
+ from strata_pool.backends.docker import DockerBackend, DockerError
4
+ from strata_pool.backends.runpod import RunPodBackend, RunPodError
5
+
6
+ __all__ = ["DockerBackend", "DockerError", "RunPodBackend", "RunPodError"]
@@ -0,0 +1,188 @@
1
+ """Run workers as containers on a local Docker daemon.
2
+
3
+ The first backend, chosen first so that dispatch, boot handling, and the
4
+ metering path are all exercised by CI before any of them produces a number
5
+ someone pays for.
6
+
7
+ It talks to the Docker Engine API over its UNIX socket with httpx rather than
8
+ pulling in the Docker SDK: the pool's dependency list stays at one entry, and
9
+ every request shape is testable against `httpx.MockTransport` without a
10
+ daemon.
11
+
12
+ The backend does not care what runs inside the container. It starts an image,
13
+ finds the host port Docker published, and reports the endpoint; whether that
14
+ image is `strata-worker` or something else is the caller's business.
15
+ """
16
+
17
+ import logging
18
+
19
+ import httpx
20
+
21
+ from strata_pool.backend import ProvisionedWorker
22
+ from strata_pool.types import MachineType
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ DEFAULT_SOCKET = "/var/run/docker.sock"
27
+
28
+
29
+ class DockerError(RuntimeError):
30
+ """The Docker daemon refused a request."""
31
+
32
+
33
+ class DockerBackend:
34
+ """Provisions workers as containers on the local Docker daemon."""
35
+
36
+ name = "docker"
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ socket_path: str = DEFAULT_SOCKET,
42
+ worker_port: int = 8080,
43
+ command: list[str] | None = None,
44
+ stop_timeout_seconds: int = 5,
45
+ api: httpx.AsyncClient | None = None,
46
+ probe: httpx.AsyncClient | None = None,
47
+ ):
48
+ """
49
+ Args:
50
+ worker_port: The port the image listens on inside the container.
51
+ Docker publishes it on an arbitrary host port, which is what
52
+ the pool connects to.
53
+ command: Overrides the image's entrypoint. For images that host
54
+ more than one, and for tests.
55
+ api: Client for the Docker Engine API. Defaults to one bound to
56
+ the daemon socket.
57
+ probe: Client for worker health checks, which go over TCP to the
58
+ published port rather than through the daemon.
59
+ """
60
+ self.worker_port = worker_port
61
+ self.command = command
62
+ self.stop_timeout_seconds = stop_timeout_seconds
63
+ self._owns_clients = api is None and probe is None
64
+ self._api = api if api is not None else _socket_client(socket_path)
65
+ self._probe = probe if probe is not None else httpx.AsyncClient()
66
+
67
+ async def aclose(self) -> None:
68
+ """Release the HTTP clients. The pool does not own the backend."""
69
+ if self._owns_clients:
70
+ await self._api.aclose()
71
+ await self._probe.aclose()
72
+
73
+ async def start(
74
+ self,
75
+ spec: MachineType,
76
+ env: dict[str, str] | None = None,
77
+ ) -> ProvisionedWorker:
78
+ port_key = f"{self.worker_port}/tcp"
79
+ host_config: dict[str, object] = {
80
+ # Empty HostPort means "pick a free one". Binding to loopback
81
+ # keeps a worker off the network: the pool is the only thing that
82
+ # should be able to reach it.
83
+ "PortBindings": {port_key: [{"HostIp": "127.0.0.1", "HostPort": ""}]},
84
+ }
85
+ # Unset means the container may consume the whole host, which is one
86
+ # tenant's job able to starve every other container on the box.
87
+ if spec.cpus is not None:
88
+ host_config["NanoCpus"] = int(spec.cpus * 1_000_000_000)
89
+ if spec.memory_mb is not None:
90
+ host_config["Memory"] = spec.memory_mb * 1024 * 1024
91
+
92
+ create = await self._api.post(
93
+ "/containers/create",
94
+ json={
95
+ "Image": spec.image,
96
+ "Env": [f"{key}={value}" for key, value in (env or {}).items()],
97
+ "Cmd": self.command,
98
+ "ExposedPorts": {port_key: {}},
99
+ "HostConfig": host_config,
100
+ # A machine the pool forgot about can still be found by hand,
101
+ # and gives a later reconcile pass something to match on.
102
+ "Labels": {"strata.pool.machine-type": spec.name},
103
+ },
104
+ )
105
+ if create.status_code >= 400:
106
+ raise DockerError(f"could not create a {spec.image} container: {_message(create)}")
107
+ container_id = create.json()["Id"]
108
+
109
+ started = await self._api.post(f"/containers/{container_id}/start")
110
+ if started.status_code >= 400:
111
+ # The container exists and would sit there costing disk, so take
112
+ # it back out before reporting the failure.
113
+ await self.stop(container_id)
114
+ raise DockerError(f"could not start {container_id}: {_message(started)}")
115
+
116
+ port = await self._published_port(container_id, port_key)
117
+ return ProvisionedWorker(
118
+ backend_id=container_id,
119
+ endpoint=f"http://127.0.0.1:{port}",
120
+ region="local",
121
+ metadata={"machine_type": spec.name},
122
+ )
123
+
124
+ async def stop(self, backend_id: str) -> None:
125
+ """Stop and remove a container. Idempotent."""
126
+ stopped = await self._api.post(
127
+ f"/containers/{backend_id}/stop",
128
+ params={"t": self.stop_timeout_seconds},
129
+ )
130
+ # 304 is "already stopped" and 404 is "already gone"; both are the
131
+ # state this method exists to reach.
132
+ if stopped.status_code >= 400 and stopped.status_code != 404:
133
+ logger.warning(
134
+ "docker refused to stop a container; removing it anyway",
135
+ extra={"container_id": backend_id, "detail": _message(stopped)},
136
+ )
137
+
138
+ removed = await self._api.delete(f"/containers/{backend_id}", params={"force": "true"})
139
+ if removed.status_code >= 400 and removed.status_code != 404:
140
+ raise DockerError(f"could not remove {backend_id}: {_message(removed)}")
141
+
142
+ async def health(self, endpoint: str) -> bool:
143
+ """Report whether the worker answers on its published port.
144
+
145
+ Never raises: a refused connection is the normal state of a container
146
+ that is still booting, and the pool polls this in a loop.
147
+ """
148
+ try:
149
+ response = await self._probe.get(f"{endpoint}/health", timeout=2.0)
150
+ except httpx.HTTPError:
151
+ return False
152
+ return response.status_code < 400
153
+
154
+ async def _published_port(self, container_id: str, port_key: str) -> int:
155
+ inspect = await self._api.get(f"/containers/{container_id}/json")
156
+ if inspect.status_code >= 400:
157
+ raise DockerError(f"could not inspect {container_id}: {_message(inspect)}")
158
+
159
+ bindings = inspect.json().get("NetworkSettings", {}).get("Ports") or {}
160
+ published = bindings.get(port_key) or []
161
+ if not published:
162
+ raise DockerError(
163
+ f"{container_id} published no host port for {port_key}. The image must "
164
+ f"listen on {self.worker_port}, or the backend needs a different worker_port."
165
+ )
166
+ return int(published[0]["HostPort"])
167
+
168
+
169
+ def _socket_client(socket_path: str) -> httpx.AsyncClient:
170
+ """A client bound to the daemon socket.
171
+
172
+ The host in the URL is ignored for a UNIX-socket transport but httpx still
173
+ requires one, hence the placeholder.
174
+ """
175
+ return httpx.AsyncClient(
176
+ transport=httpx.AsyncHTTPTransport(uds=socket_path),
177
+ base_url="http://docker",
178
+ timeout=30.0,
179
+ )
180
+
181
+
182
+ def _message(response: httpx.Response) -> str:
183
+ """The daemon's own error text, which is JSON when it is well behaved."""
184
+ try:
185
+ payload = response.json()
186
+ except ValueError:
187
+ return f"HTTP {response.status_code}: {response.text[:200]}"
188
+ return f"HTTP {response.status_code}: {payload.get('message', response.text[:200])}"