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.
@@ -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