cortexgrid 0.2.85__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.
- cortexgrid/__init__.py +195 -0
- cortexgrid/_bundle.py +207 -0
- cortexgrid/_ray_job_driver.py +48 -0
- cortexgrid/_serve_entry.py +39 -0
- cortexgrid/checkpoint.py +217 -0
- cortexgrid/experiment.py +223 -0
- cortexgrid/infra.py +39 -0
- cortexgrid/jobs.py +292 -0
- cortexgrid/mlflow_util.py +110 -0
- cortexgrid/model_serving.py +365 -0
- cortexgrid/model_storage.py +255 -0
- cortexgrid/py.typed +0 -0
- cortexgrid/ray_util.py +171 -0
- cortexgrid/s3_util.py +135 -0
- cortexgrid/secrets.py +56 -0
- cortexgrid-0.2.85.dist-info/METADATA +199 -0
- cortexgrid-0.2.85.dist-info/RECORD +19 -0
- cortexgrid-0.2.85.dist-info/WHEEL +4 -0
- cortexgrid-0.2.85.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Cortexgrid wrappers around Ray Serve.
|
|
2
|
+
|
|
3
|
+
Caller stays HTTP-only: deploy/undeploy/list talk to the Ray dashboard's
|
|
4
|
+
declarative `/api/serve/applications/` endpoint via [cortexgrid.ray_util],
|
|
5
|
+
never `ray.init`. The deployment class is bundled at `save_model` time, zipped,
|
|
6
|
+
uploaded to MinIO under `serve-bundles/<run_name>/<family>__<suffix>.zip`, and
|
|
7
|
+
referenced via `runtime_env.working_dir` so Ray workers fetch it from there.
|
|
8
|
+
The bundle URL, class import path, and pip list are persisted as MLflow tags
|
|
9
|
+
on the ModelVersion so `deploy_model` can find them later without the caller
|
|
10
|
+
holding the class object.
|
|
11
|
+
|
|
12
|
+
Naming: the Ray Serve application is named "<family>__<suffix>__<run_name>".
|
|
13
|
+
This relies on family/suffix/run_name not containing the literal "__".
|
|
14
|
+
|
|
15
|
+
See [docs/cortexgrid/model-serving.md](../docs/cortexgrid/model-serving.md) for
|
|
16
|
+
the end-to-end design.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import inspect
|
|
22
|
+
import logging
|
|
23
|
+
import shutil
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from mlflow.tracking import MlflowClient
|
|
31
|
+
from ray.serve.schema import ApplicationStatus
|
|
32
|
+
|
|
33
|
+
from cortexgrid._bundle import bundle, stage, worker_provides
|
|
34
|
+
from cortexgrid.infra import get_mlflow_tracking_uri, get_ray_serve_uri
|
|
35
|
+
from cortexgrid.ray_util import (
|
|
36
|
+
get_serve_details,
|
|
37
|
+
put_serve_applications,
|
|
38
|
+
)
|
|
39
|
+
from cortexgrid.s3_util import upload
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Deployment:
|
|
47
|
+
"""A scheduled Ray Serve app fronting a model. `phase` is the normalized
|
|
48
|
+
serving lifecycle phase (see `ServingStatus`); an app that appears in a
|
|
49
|
+
listing always exists, so its phase is never "not_deployed"."""
|
|
50
|
+
|
|
51
|
+
family: str
|
|
52
|
+
suffix: str
|
|
53
|
+
run_name: str
|
|
54
|
+
url: str
|
|
55
|
+
phase: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _app_name(family: str, suffix: str, run_name: str) -> str:
|
|
59
|
+
return f"{family}__{suffix}__{run_name}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _route_prefix(family: str, suffix: str, run_name: str) -> str:
|
|
63
|
+
return f"/r/{family}/{suffix}/{run_name}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_PHASE_NOT_DEPLOYED = "not_deployed"
|
|
67
|
+
|
|
68
|
+
# Ray Serve ApplicationStatus -> normalized serving phase. The single source of
|
|
69
|
+
# the serving vocabulary, shared by Deployment, list_deployed_models, and
|
|
70
|
+
# model_serving_status.
|
|
71
|
+
_PHASE_BY_SERVE_STATUS = {
|
|
72
|
+
ApplicationStatus.RUNNING.value: "running",
|
|
73
|
+
ApplicationStatus.DEPLOYING.value: "deploying",
|
|
74
|
+
ApplicationStatus.NOT_STARTED.value: "not_started",
|
|
75
|
+
ApplicationStatus.UNHEALTHY.value: "unhealthy",
|
|
76
|
+
ApplicationStatus.DEPLOY_FAILED.value: "failed",
|
|
77
|
+
ApplicationStatus.DELETING.value: "deleting",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _serve_phase(raw_status: str) -> str:
|
|
82
|
+
"""Map a Ray Serve app status to the normalized serving phase, defaulting to
|
|
83
|
+
"deploying" for an app that exists but has no recognized status yet."""
|
|
84
|
+
return _PHASE_BY_SERVE_STATUS.get(raw_status, "deploying")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class BundleMetadata:
|
|
89
|
+
"""What `bundle_class` produces and `deploy_model` needs to PUT the app."""
|
|
90
|
+
|
|
91
|
+
bundle_url: str
|
|
92
|
+
class_import_path: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def bundle_class(
|
|
96
|
+
cls: type, family: str, suffix: str, run_name: str
|
|
97
|
+
) -> BundleMetadata:
|
|
98
|
+
"""Bundle the serve-app class's code (and the serve entrypoint), zip it, and
|
|
99
|
+
upload to MinIO.
|
|
100
|
+
|
|
101
|
+
Returns the metadata `deploy_model` needs later; callers (typically
|
|
102
|
+
`save_model`) persist it on the ModelVersion so the deploy step can run
|
|
103
|
+
without holding the class object."""
|
|
104
|
+
entry_file = Path(inspect.getfile(cls)).resolve()
|
|
105
|
+
serve_entry = Path(__file__).with_name("_serve_entry.py")
|
|
106
|
+
files = bundle(entry_file).merge(bundle(serve_entry)).local_files - worker_provides()
|
|
107
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
108
|
+
code_root = Path(tmp) / "code"
|
|
109
|
+
stage(files, code_root)
|
|
110
|
+
log.info(
|
|
111
|
+
"Serve bundle for %s/%s/%s: %d files", family, suffix, run_name, len(files)
|
|
112
|
+
)
|
|
113
|
+
zip_base = Path(tmp) / f"{family}__{suffix}"
|
|
114
|
+
shutil.make_archive(str(zip_base), "zip", root_dir=str(code_root))
|
|
115
|
+
bundle_url = upload(
|
|
116
|
+
str(zip_base.with_suffix(".zip")),
|
|
117
|
+
dest_path=f"serve-bundles/{run_name}/{family}__{suffix}.zip",
|
|
118
|
+
)
|
|
119
|
+
return BundleMetadata(
|
|
120
|
+
bundle_url=bundle_url,
|
|
121
|
+
class_import_path=f"{cls.__module__}:{cls.__name__}",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_application_spec(
|
|
126
|
+
family: str, suffix: str, run_name: str, meta: BundleMetadata
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""Assemble a Ray Serve application schema from pre-bundled metadata."""
|
|
129
|
+
return {
|
|
130
|
+
"name": _app_name(family, suffix, run_name),
|
|
131
|
+
"route_prefix": _route_prefix(family, suffix, run_name),
|
|
132
|
+
# Ray Serve REST requires import_path to point at an Application builder
|
|
133
|
+
# (callable returning a bound node) or an already-bound node. A bare
|
|
134
|
+
# Deployment class is rejected, so cortexgrid.deploy_model goes through
|
|
135
|
+
# a generic builder that re-imports the user's class and binds it.
|
|
136
|
+
"import_path": "cortexgrid._serve_entry:build",
|
|
137
|
+
"args": {
|
|
138
|
+
"class_import_path": meta.class_import_path,
|
|
139
|
+
"family": family,
|
|
140
|
+
"suffix": suffix,
|
|
141
|
+
"run_name": run_name,
|
|
142
|
+
},
|
|
143
|
+
# Every dependency ships as source inside the bundle, so working_dir
|
|
144
|
+
# alone makes the serve app importable; nothing is pip-installed.
|
|
145
|
+
"runtime_env": {"working_dir": meta.bundle_url},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# MLflow tag keys for the bundle metadata `save_model` writes and
|
|
150
|
+
# `deploy_model` reads back.
|
|
151
|
+
_CLASS_IMPORT_PATH_TAG = "class_import_path"
|
|
152
|
+
_BUNDLE_URL_TAG = "serve_bundle_url"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def metadata_to_tags(meta: BundleMetadata) -> dict[str, str]:
|
|
156
|
+
"""Serialise BundleMetadata to MLflow tags. The inverse of
|
|
157
|
+
`_load_bundle_metadata`; lives here next to the consumer so the tag schema
|
|
158
|
+
stays in one place."""
|
|
159
|
+
return {
|
|
160
|
+
_CLASS_IMPORT_PATH_TAG: meta.class_import_path,
|
|
161
|
+
_BUNDLE_URL_TAG: meta.bundle_url,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _load_bundle_metadata(
|
|
166
|
+
family: str, suffix: str, run_name: str
|
|
167
|
+
) -> BundleMetadata:
|
|
168
|
+
"""Read the bundle metadata `save_model` persisted on the ModelVersion."""
|
|
169
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
170
|
+
name = f"{family}__{suffix}"
|
|
171
|
+
versions = client.search_model_versions(
|
|
172
|
+
f"name='{name}' and tags.run_name='{run_name}'"
|
|
173
|
+
)
|
|
174
|
+
if not versions:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"No saved model for {family}/{suffix}/{run_name}; cannot deploy."
|
|
177
|
+
)
|
|
178
|
+
tags = versions[0].tags or {}
|
|
179
|
+
try:
|
|
180
|
+
return BundleMetadata(
|
|
181
|
+
bundle_url=tags[_BUNDLE_URL_TAG],
|
|
182
|
+
class_import_path=tags[_CLASS_IMPORT_PATH_TAG],
|
|
183
|
+
)
|
|
184
|
+
except KeyError as exc:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"Saved model {family}/{suffix}/{run_name} is missing the deployment "
|
|
187
|
+
f"bundle tag {exc.args[0]!r}; re-save with `cortexgrid.save_model`."
|
|
188
|
+
) from exc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _current_application_specs() -> list[dict[str, Any]]:
|
|
192
|
+
"""Reconstruct the most recently PUT applications list from GET output.
|
|
193
|
+
|
|
194
|
+
Ray stores the originally-deployed `ServeApplicationSchema` for each app
|
|
195
|
+
under `applications[<name>].deployed_app_config`, which is what we need to
|
|
196
|
+
PUT-round-trip. Apps without a `deployed_app_config` (e.g. created via
|
|
197
|
+
`serve.run` in-cluster) are skipped: we can't faithfully reproduce them
|
|
198
|
+
from the read-only view.
|
|
199
|
+
"""
|
|
200
|
+
details = get_serve_details()
|
|
201
|
+
specs: list[dict[str, Any]] = []
|
|
202
|
+
for app in details.get("applications", {}).values():
|
|
203
|
+
cfg = app.get("deployed_app_config")
|
|
204
|
+
if cfg is not None:
|
|
205
|
+
specs.append(cfg)
|
|
206
|
+
return specs
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _wait_for_application_running(
|
|
210
|
+
name: str, timeout_s: float | None = 300.0, interval_s: float = 2.0
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Poll the Serve controller until the named application is RUNNING.
|
|
213
|
+
|
|
214
|
+
Raises immediately on DEPLOY_FAILED with the controller's message. Other
|
|
215
|
+
non-RUNNING statuses (NOT_STARTED, DEPLOYING, UNHEALTHY) are treated as
|
|
216
|
+
transient until the timeout fires. With `timeout_s=None` there is no
|
|
217
|
+
deadline: the loop blocks until a terminal status (RUNNING or
|
|
218
|
+
DEPLOY_FAILED) is reached.
|
|
219
|
+
"""
|
|
220
|
+
deadline = None if timeout_s is None else time.monotonic() + timeout_s
|
|
221
|
+
last_status: str = "(missing)"
|
|
222
|
+
last_message: str = ""
|
|
223
|
+
while deadline is None or time.monotonic() < deadline:
|
|
224
|
+
app = get_serve_details().get("applications", {}).get(name)
|
|
225
|
+
if app is not None:
|
|
226
|
+
last_status = str(app.get("status", "(missing)"))
|
|
227
|
+
last_message = str(app.get("message", ""))
|
|
228
|
+
if last_status == ApplicationStatus.RUNNING.value:
|
|
229
|
+
return
|
|
230
|
+
if last_status == ApplicationStatus.DEPLOY_FAILED.value:
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
f"Serve app {name!r} DEPLOY_FAILED: {last_message}"
|
|
233
|
+
)
|
|
234
|
+
time.sleep(interval_s)
|
|
235
|
+
raise TimeoutError(
|
|
236
|
+
f"Serve app {name!r} did not reach RUNNING within {timeout_s}s "
|
|
237
|
+
f"(last status={last_status!r}, message={last_message!r})"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def deploy_model(
|
|
242
|
+
family: str,
|
|
243
|
+
suffix: str,
|
|
244
|
+
run_name: str,
|
|
245
|
+
wait: bool = False,
|
|
246
|
+
timeout: float | None = 300.0,
|
|
247
|
+
) -> Deployment:
|
|
248
|
+
"""Schedule a Ray Serve app for a previously-saved model and return a
|
|
249
|
+
handle carrying its base URL. The caller (e.g. model-gateway) builds
|
|
250
|
+
whatever client the app's routes need - streaming, long timeouts, custom
|
|
251
|
+
request schemas - against that URL; cortexgrid imposes no traffic contract.
|
|
252
|
+
|
|
253
|
+
The serve-app class is pulled from the MLflow ModelVersion tags `save_model`
|
|
254
|
+
wrote at save time; the caller does not need to hold the class object.
|
|
255
|
+
|
|
256
|
+
With `wait=True`, blocks until the Serve controller reports the app
|
|
257
|
+
RUNNING, capped at `timeout` seconds (default 300). DEPLOY_FAILED raises;
|
|
258
|
+
exceeding a finite `timeout` raises TimeoutError. With `timeout=None` the
|
|
259
|
+
wait is unbounded: it blocks until a terminal status (RUNNING or
|
|
260
|
+
DEPLOY_FAILED) is reached. Tradeoff: an app that never reaches a terminal
|
|
261
|
+
state (e.g. GPU-starved, stuck in DEPLOYING) will hang forever.
|
|
262
|
+
"""
|
|
263
|
+
meta = _load_bundle_metadata(family, suffix, run_name)
|
|
264
|
+
spec = _build_application_spec(family, suffix, run_name, meta)
|
|
265
|
+
existing = [a for a in _current_application_specs() if a["name"] != spec["name"]]
|
|
266
|
+
put_serve_applications([*existing, spec])
|
|
267
|
+
if wait:
|
|
268
|
+
_wait_for_application_running(spec["name"], timeout_s=timeout)
|
|
269
|
+
app = get_serve_details().get("applications", {}).get(spec["name"], {})
|
|
270
|
+
return Deployment(
|
|
271
|
+
family=family,
|
|
272
|
+
suffix=suffix,
|
|
273
|
+
run_name=run_name,
|
|
274
|
+
url=f"{get_ray_serve_uri()}{_route_prefix(family, suffix, run_name)}",
|
|
275
|
+
phase=_serve_phase(str(app.get("status", ""))),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def undeploy_model(family: str, suffix: str, run_name: str) -> None:
|
|
280
|
+
"""Tear down the Ray Serve app for this model."""
|
|
281
|
+
name = _app_name(family, suffix, run_name)
|
|
282
|
+
remaining = [a for a in _current_application_specs() if a["name"] != name]
|
|
283
|
+
put_serve_applications(remaining)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def list_deployed_models() -> list[Deployment]:
|
|
287
|
+
"""Return Deployment records for every Ray Serve app whose name matches our scheme."""
|
|
288
|
+
base = get_ray_serve_uri()
|
|
289
|
+
details = get_serve_details()
|
|
290
|
+
result: list[Deployment] = []
|
|
291
|
+
for app_name, app in details.get("applications", {}).items():
|
|
292
|
+
parts = app_name.split("__")
|
|
293
|
+
if len(parts) != 3:
|
|
294
|
+
continue
|
|
295
|
+
family, suffix, run_name = parts
|
|
296
|
+
result.append(
|
|
297
|
+
Deployment(
|
|
298
|
+
family=family,
|
|
299
|
+
suffix=suffix,
|
|
300
|
+
run_name=run_name,
|
|
301
|
+
url=f"{base}{_route_prefix(family, suffix, run_name)}",
|
|
302
|
+
phase=_serve_phase(str(app.get("status", ""))),
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass
|
|
309
|
+
class ServingStatus:
|
|
310
|
+
"""Serving lifecycle of one model, owned by the Ray Serve controller.
|
|
311
|
+
|
|
312
|
+
This is the serving half of a model's life. The registry half (uploading /
|
|
313
|
+
ready in MLflow) is a separate lifecycle reported by
|
|
314
|
+
`cortexgrid.model_storage.model_registry_status`.
|
|
315
|
+
|
|
316
|
+
`phase` is one of:
|
|
317
|
+
- "not_deployed" no Serve app: never deployed, or already undeployed
|
|
318
|
+
- "not_started" controller accepted the app but has not started it yet
|
|
319
|
+
(NOT_STARTED)
|
|
320
|
+
- "deploying" replicas starting; the replica pulls the weights and
|
|
321
|
+
builds the model on the worker (DEPLOYING)
|
|
322
|
+
- "running" serving traffic (RUNNING)
|
|
323
|
+
- "unhealthy" Serve app reports UNHEALTHY
|
|
324
|
+
- "failed" Serve app DEPLOY_FAILED
|
|
325
|
+
- "deleting" Serve app being torn down (DELETING)
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
family: str
|
|
329
|
+
suffix: str
|
|
330
|
+
run_name: str
|
|
331
|
+
phase: str
|
|
332
|
+
message: str
|
|
333
|
+
url: str | None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def model_serving_status(
|
|
337
|
+
family: str, suffix: str, run_name: str
|
|
338
|
+
) -> ServingStatus:
|
|
339
|
+
"""Report the serving lifecycle phase of a model from the Ray Serve
|
|
340
|
+
controller, HTTP-only.
|
|
341
|
+
|
|
342
|
+
The serving lifecycle begins when `deploy_model` schedules the app and ends
|
|
343
|
+
when `undeploy_model` tears it down; outside that window the phase is
|
|
344
|
+
"not_deployed". While an app exists the phase reflects the controller's
|
|
345
|
+
status ("deploying" while the replica pulls weights and builds the model on
|
|
346
|
+
the worker, then "running"). See `ServingStatus` for the full vocabulary.
|
|
347
|
+
The registry lifecycle is reported separately by
|
|
348
|
+
`cortexgrid.model_storage.model_registry_status`.
|
|
349
|
+
"""
|
|
350
|
+
app = get_serve_details().get("applications", {}).get(
|
|
351
|
+
_app_name(family, suffix, run_name)
|
|
352
|
+
)
|
|
353
|
+
if app is None:
|
|
354
|
+
return ServingStatus(
|
|
355
|
+
family, suffix, run_name, _PHASE_NOT_DEPLOYED, "", None
|
|
356
|
+
)
|
|
357
|
+
raw = str(app.get("status", ""))
|
|
358
|
+
return ServingStatus(
|
|
359
|
+
family=family,
|
|
360
|
+
suffix=suffix,
|
|
361
|
+
run_name=run_name,
|
|
362
|
+
phase=_serve_phase(raw),
|
|
363
|
+
message=str(app.get("message", "")) or raw,
|
|
364
|
+
url=f"{get_ray_serve_uri()}{_route_prefix(family, suffix, run_name)}",
|
|
365
|
+
)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Model registry backed by MLflow Model Registry; weights stored directly in S3.
|
|
2
|
+
|
|
3
|
+
Mapping cortexgrid taxonomy <-> MLflow Registry:
|
|
4
|
+
family + suffix -> RegisteredModel.name = "<family>/<suffix>"
|
|
5
|
+
run_name -> ModelVersion.tags["run_name"]
|
|
6
|
+
family, suffix -> ModelVersion.tags["family"], ["suffix"] (denormalized)
|
|
7
|
+
weights blob path -> ModelVersion.source =
|
|
8
|
+
"s3://<bucket>/models/<run_name>/<family>/<suffix>/weights/"
|
|
9
|
+
run linkage -> ModelVersion.run_id (built-in MLflow field)
|
|
10
|
+
|
|
11
|
+
storage.py is pure: it takes run_id/run_name as explicit args and never reads
|
|
12
|
+
the active Experiment singleton. The facade that fills those in lives in
|
|
13
|
+
cortexgrid/__init__.py.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
import tempfile
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from mlflow.exceptions import MlflowException
|
|
26
|
+
from mlflow.tracking import MlflowClient
|
|
27
|
+
|
|
28
|
+
from cortexgrid import s3_util
|
|
29
|
+
from cortexgrid.infra import get_mlflow_tracking_uri, get_s3_bucket
|
|
30
|
+
from cortexgrid.model_serving import (
|
|
31
|
+
bundle_class,
|
|
32
|
+
metadata_to_tags,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# MLflow ModelVersion tag holding the registry lifecycle phase, and its
|
|
37
|
+
# values. This is a separate lifecycle from serving (Ray Serve); see
|
|
38
|
+
# cortexgrid.model_serving for that vocabulary.
|
|
39
|
+
_LIFECYCLE_TAG = "lifecycle"
|
|
40
|
+
_PHASE_UPLOADING = "uploading"
|
|
41
|
+
_PHASE_READY = "ready"
|
|
42
|
+
_PHASE_UPLOAD_FAILED = "upload_failed"
|
|
43
|
+
_PHASE_BROKEN = "broken"
|
|
44
|
+
|
|
45
|
+
# An upload still marked "uploading" this long after the version was created is
|
|
46
|
+
# treated as broken: save_model creates the version immediately before the
|
|
47
|
+
# upload begins, so creation_timestamp is the upload start, and a process that
|
|
48
|
+
# dies mid-upload never flips the tag to "ready"/"upload_failed". Expiry is
|
|
49
|
+
# derived lazily on read (see `_phase_for`); nothing is written back.
|
|
50
|
+
_UPLOAD_DEADLINE = timedelta(hours=3)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SavedModel:
|
|
55
|
+
family: str
|
|
56
|
+
suffix: str
|
|
57
|
+
run_name: str
|
|
58
|
+
created_at: str
|
|
59
|
+
data_blob_path: str
|
|
60
|
+
size_bytes: int
|
|
61
|
+
# Registry lifecycle phase: "uploading" while save_model streams the weights
|
|
62
|
+
# and serve bundle to storage, "ready" once that finishes, "upload_failed"
|
|
63
|
+
# if it errored, "broken" if an upload has stayed in progress past
|
|
64
|
+
# _UPLOAD_DEADLINE (writer presumed dead). Versions written before this tag
|
|
65
|
+
# existed report "ready".
|
|
66
|
+
phase: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _phase_for(version: Any) -> str:
|
|
70
|
+
"""Registry lifecycle phase of a version, expiring stale uploads to "broken".
|
|
71
|
+
|
|
72
|
+
Reads the lifecycle tag, but an upload that has stayed "uploading" longer
|
|
73
|
+
than _UPLOAD_DEADLINE (measured from creation_timestamp, i.e. the upload
|
|
74
|
+
start) is reported as "broken" instead."""
|
|
75
|
+
phase = version.tags.get(_LIFECYCLE_TAG, _PHASE_READY)
|
|
76
|
+
if phase != _PHASE_UPLOADING:
|
|
77
|
+
return phase
|
|
78
|
+
started = datetime.fromtimestamp(
|
|
79
|
+
version.creation_timestamp / 1000, tz=timezone.utc
|
|
80
|
+
)
|
|
81
|
+
if datetime.now(timezone.utc) - started > _UPLOAD_DEADLINE:
|
|
82
|
+
return _PHASE_BROKEN
|
|
83
|
+
return phase
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _to_saved_model(version: Any) -> SavedModel:
|
|
87
|
+
return SavedModel(
|
|
88
|
+
family=version.tags["family"],
|
|
89
|
+
suffix=version.tags["suffix"],
|
|
90
|
+
run_name=version.tags["run_name"],
|
|
91
|
+
created_at=datetime.fromtimestamp(
|
|
92
|
+
version.creation_timestamp / 1000, tz=timezone.utc
|
|
93
|
+
).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
94
|
+
data_blob_path=version.source,
|
|
95
|
+
size_bytes=int(version.tags.get("size_bytes", "0")),
|
|
96
|
+
phase=_phase_for(version),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _dir_size_bytes(local_dir: str | Path) -> int:
|
|
101
|
+
total = 0
|
|
102
|
+
for root, _dirs, files in os.walk(local_dir):
|
|
103
|
+
for filename in files:
|
|
104
|
+
total += os.path.getsize(os.path.join(root, filename))
|
|
105
|
+
return total
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ensure_registered_model(client: MlflowClient, name: str) -> None:
|
|
109
|
+
try:
|
|
110
|
+
client.get_registered_model(name)
|
|
111
|
+
except MlflowException:
|
|
112
|
+
client.create_registered_model(name)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _download_s3_uri(uri: str, dest_dir: str | Path | None) -> Path:
|
|
116
|
+
bucket, _, key_prefix = uri.removeprefix("s3://").partition("/")
|
|
117
|
+
dest = Path(dest_dir) if dest_dir else Path(tempfile.mkdtemp())
|
|
118
|
+
client = s3_util.get_s3_client()
|
|
119
|
+
paginator = client.get_paginator("list_objects_v2")
|
|
120
|
+
for page in paginator.paginate(Bucket=bucket, Prefix=key_prefix):
|
|
121
|
+
for obj in page.get("Contents", []):
|
|
122
|
+
rel = obj["Key"][len(key_prefix) :]
|
|
123
|
+
target = dest / rel
|
|
124
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
client.download_file(bucket, obj["Key"], str(target))
|
|
126
|
+
return dest
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def save_model(
|
|
130
|
+
weights_dir: str | Path,
|
|
131
|
+
serve_app: type,
|
|
132
|
+
suffix: str,
|
|
133
|
+
family: str,
|
|
134
|
+
run_id: str,
|
|
135
|
+
run_name: str,
|
|
136
|
+
) -> SavedModel:
|
|
137
|
+
"""Upload a weights directory to S3 and register a new MLflow ModelVersion
|
|
138
|
+
paired with the serve-app that fronts it.
|
|
139
|
+
|
|
140
|
+
cortexgrid stores the weights as an opaque directory: it never inspects,
|
|
141
|
+
serializes, or reconstructs their contents, so the on-disk format
|
|
142
|
+
(HuggingFace `save_pretrained`, `torch.save`, ONNX, anything) is entirely
|
|
143
|
+
the caller's concern. That directory boundary is the open-closed extension
|
|
144
|
+
point - new model kinds need no change here.
|
|
145
|
+
|
|
146
|
+
`serve_app` is the Ray Serve ingress class that will front these weights.
|
|
147
|
+
Its code is bundled and its import path, bundle URL, and pip list are
|
|
148
|
+
stored as tags on the ModelVersion so `deploy_model` can bind it later
|
|
149
|
+
without the caller holding the class object."""
|
|
150
|
+
bucket = get_s3_bucket()
|
|
151
|
+
prefix = f"models/{run_name}/{family}/{suffix}"
|
|
152
|
+
size_bytes = _dir_size_bytes(weights_dir)
|
|
153
|
+
source = f"s3://{bucket}/{prefix}/weights/"
|
|
154
|
+
name = f"{family}__{suffix}"
|
|
155
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
156
|
+
_ensure_registered_model(client, name)
|
|
157
|
+
# Register the version up front in the "uploading" phase so the dashboard
|
|
158
|
+
# can surface a model while its weights are still streaming to storage. The
|
|
159
|
+
# bundle tags and the flip to "ready" happen only after the upload lands.
|
|
160
|
+
version = client.create_model_version(
|
|
161
|
+
name=name,
|
|
162
|
+
source=source,
|
|
163
|
+
run_id=run_id,
|
|
164
|
+
tags={
|
|
165
|
+
"family": family,
|
|
166
|
+
"suffix": suffix,
|
|
167
|
+
"run_name": run_name,
|
|
168
|
+
"size_bytes": str(size_bytes),
|
|
169
|
+
_LIFECYCLE_TAG: _PHASE_UPLOADING,
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
s3_util.upload_dir(str(weights_dir), dest_path=f"{prefix}/weights")
|
|
174
|
+
bundle_meta = bundle_class(serve_app, family, suffix, run_name)
|
|
175
|
+
for key, value in metadata_to_tags(bundle_meta).items():
|
|
176
|
+
client.set_model_version_tag(name, version.version, key, value)
|
|
177
|
+
client.set_model_version_tag(
|
|
178
|
+
name, version.version, _LIFECYCLE_TAG, _PHASE_READY
|
|
179
|
+
)
|
|
180
|
+
except Exception:
|
|
181
|
+
client.set_model_version_tag(
|
|
182
|
+
name, version.version, _LIFECYCLE_TAG, _PHASE_UPLOAD_FAILED
|
|
183
|
+
)
|
|
184
|
+
raise
|
|
185
|
+
return _to_saved_model(client.get_model_version(name, version.version))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_model(family: str, suffix: str, run_name: str) -> Path:
|
|
189
|
+
"""Download a saved model's weights to a local directory and return its Path.
|
|
190
|
+
|
|
191
|
+
cortexgrid moves an opaque directory of bytes and never interprets its
|
|
192
|
+
contents; the serve-app reconstructs the model from it however it likes
|
|
193
|
+
(`from_pretrained`, `torch.load`, ...). The returned directory persists
|
|
194
|
+
after this call - the caller (typically a serve-app loading weights at
|
|
195
|
+
startup) owns its lifetime."""
|
|
196
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
197
|
+
name = f"{family}__{suffix}"
|
|
198
|
+
versions = client.search_model_versions(
|
|
199
|
+
f"name='{name}' and tags.run_name='{run_name}'"
|
|
200
|
+
)
|
|
201
|
+
if not versions or not versions[0].source:
|
|
202
|
+
raise ValueError(f"No model {family}/{suffix}/{run_name}")
|
|
203
|
+
return _download_s3_uri(versions[0].source, None)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def list_models() -> list[SavedModel]:
|
|
207
|
+
"""Return SavedModel records for every ModelVersion in the registry,
|
|
208
|
+
including versions still uploading or whose upload failed (see
|
|
209
|
+
`SavedModel.phase`)."""
|
|
210
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
211
|
+
return [_to_saved_model(v) for v in client.search_model_versions("")]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def model_registry_status(
|
|
215
|
+
family: str, suffix: str, run_name: str
|
|
216
|
+
) -> SavedModel | None:
|
|
217
|
+
"""Report the registry lifecycle of one model, or None if it was never
|
|
218
|
+
registered (no upload ever started).
|
|
219
|
+
|
|
220
|
+
The registry lifecycle is owned here: it begins when `save_model` creates
|
|
221
|
+
the ModelVersion (`phase="uploading"`), becomes `"ready"` once the weights
|
|
222
|
+
and serve bundle finish uploading, `"upload_failed"` if the upload errored,
|
|
223
|
+
or `"broken"` if an upload has stayed in progress past _UPLOAD_DEADLINE
|
|
224
|
+
(the writer is presumed dead). Serving is a separate lifecycle; see
|
|
225
|
+
`cortexgrid.model_serving.model_serving_status`.
|
|
226
|
+
"""
|
|
227
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
228
|
+
versions = client.search_model_versions(
|
|
229
|
+
f"name='{family}__{suffix}' and tags.run_name='{run_name}'"
|
|
230
|
+
)
|
|
231
|
+
return _to_saved_model(versions[0]) if versions else None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def delete_model(family: str, suffix: str, run_name: str) -> None:
|
|
235
|
+
"""Delete the ModelVersion in MLflow, its weights blob, and its serve bundle."""
|
|
236
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
237
|
+
name = f"{family}__{suffix}"
|
|
238
|
+
versions = client.search_model_versions(
|
|
239
|
+
f"name='{name}' and tags.run_name='{run_name}'"
|
|
240
|
+
)
|
|
241
|
+
for v in versions:
|
|
242
|
+
client.delete_model_version(name=v.name, version=v.version)
|
|
243
|
+
s3_util.delete_prefix(f"models/{run_name}/{family}/{suffix}/")
|
|
244
|
+
s3_util.delete_prefix(f"serve-bundles/{run_name}/{family}__{suffix}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def delete_models_for_run(run_id: str) -> None:
|
|
248
|
+
"""Delete every ModelVersion produced by an MLflow run, plus its blobs and serve bundles."""
|
|
249
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
250
|
+
run = client.get_run(run_id)
|
|
251
|
+
run_name = run.info.run_name or run_id
|
|
252
|
+
for v in client.search_model_versions(f"run_id='{run_id}'"):
|
|
253
|
+
client.delete_model_version(name=v.name, version=v.version)
|
|
254
|
+
s3_util.delete_prefix(f"models/{run_name}/")
|
|
255
|
+
s3_util.delete_prefix(f"serve-bundles/{run_name}/")
|
cortexgrid/py.typed
ADDED
|
File without changes
|