hugpy-storage 0.2.0a0__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.
- hugpy_storage/__init__.py +56 -0
- hugpy_storage/admission.py +435 -0
- hugpy_storage/archive_mark.py +136 -0
- hugpy_storage/catalog_source.py +269 -0
- hugpy_storage/cli.py +98 -0
- hugpy_storage/console/__init__.py +0 -0
- hugpy_storage/console/cancelable_downloads.py +87 -0
- hugpy_storage/console/downloader.py +113 -0
- hugpy_storage/console/downloads.py +57 -0
- hugpy_storage/console/model_physical.py +557 -0
- hugpy_storage/deploy/hugpy-downloader-dev.service +58 -0
- hugpy_storage/download_models.py +751 -0
- hugpy_storage/downloader/__init__.py +34 -0
- hugpy_storage/downloader/__main__.py +8 -0
- hugpy_storage/downloader/daemon.py +333 -0
- hugpy_storage/downloader/engine.py +577 -0
- hugpy_storage/downloader/presence.py +73 -0
- hugpy_storage/downloader/queue.py +253 -0
- hugpy_storage/env.py +16 -0
- hugpy_storage/events.py +53 -0
- hugpy_storage/format_select.py +252 -0
- hugpy_storage/gguf_inspect.py +705 -0
- hugpy_storage/hf_metadata.py +29 -0
- hugpy_storage/hf_token.py +228 -0
- hugpy_storage/huggingface_api.py +240 -0
- hugpy_storage/hugpy_marker.py +811 -0
- hugpy_storage/model_config_shim.py +16 -0
- hugpy_storage/model_metadata.py +640 -0
- hugpy_storage/model_paths.py +439 -0
- hugpy_storage/model_physical.py +614 -0
- hugpy_storage/model_presence.py +321 -0
- hugpy_storage/model_status_cache.py +413 -0
- hugpy_storage/model_sync.py +162 -0
- hugpy_storage/providers.py +151 -0
- hugpy_storage/provision.py +2143 -0
- hugpy_storage/py.typed +0 -0
- hugpy_storage/schemas/__init__.py +0 -0
- hugpy_storage/schemas/download_schemas.py +43 -0
- hugpy_storage-0.2.0a0.dist-info/METADATA +41 -0
- hugpy_storage-0.2.0a0.dist-info/RECORD +44 -0
- hugpy_storage-0.2.0a0.dist-info/WHEEL +5 -0
- hugpy_storage-0.2.0a0.dist-info/entry_points.txt +3 -0
- hugpy_storage-0.2.0a0.dist-info/licenses/LICENSE +41 -0
- hugpy_storage-0.2.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""hugpy_storage — bytes at rest and in transit.
|
|
2
|
+
|
|
3
|
+
The download queue and daemon (``downloader``), resumable central/HF transfer
|
|
4
|
+
(``provision``, ``model_sync``, ``download_models``), Hugging Face transport
|
|
5
|
+
and token (``huggingface_api``, ``hf_token``, ``model_metadata``), physical
|
|
6
|
+
inventory and its caches (``model_physical``, ``model_status_cache``,
|
|
7
|
+
``model_presence``), the on-disk model layout (``model_paths``,
|
|
8
|
+
``hugpy_marker``, ``gguf_inspect``) and the console-side helpers.
|
|
9
|
+
|
|
10
|
+
Seams to the layers above (installed by the composition root):
|
|
11
|
+
|
|
12
|
+
* :mod:`hugpy_storage.catalog_source` — the model registry Protocol
|
|
13
|
+
(``set_catalog_source``); null default knows no models.
|
|
14
|
+
* :mod:`hugpy_storage.providers` — budget gate, transfer telemetry, executor
|
|
15
|
+
registrar, serve-path hook, footprint selector.
|
|
16
|
+
* :mod:`hugpy_storage.events` — ``catalog.changed`` on the control bus after
|
|
17
|
+
the inventory moves.
|
|
18
|
+
|
|
19
|
+
This ``__init__`` is deliberately light: nothing heavy (huggingface_hub,
|
|
20
|
+
requests, pydantic, sqlite stores) is imported here.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from hugpy_storage.catalog_source import (
|
|
25
|
+
CatalogSource,
|
|
26
|
+
DictCatalogSource,
|
|
27
|
+
NullCatalogSource,
|
|
28
|
+
get_catalog_source,
|
|
29
|
+
set_catalog_source,
|
|
30
|
+
)
|
|
31
|
+
from hugpy_storage.events import publish_catalog_changed
|
|
32
|
+
from hugpy_storage.providers import (
|
|
33
|
+
set_budget_gate,
|
|
34
|
+
set_executor_registrar,
|
|
35
|
+
set_footprint_selector,
|
|
36
|
+
set_serve_path_hook,
|
|
37
|
+
set_transfer_telemetry,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try: # the installed distribution's version: the workspace tag/commit, never a literal
|
|
41
|
+
from importlib.metadata import version as _dist_version
|
|
42
|
+
__version__ = _dist_version("hugpy-storage")
|
|
43
|
+
except Exception: # noqa: BLE001 — source tree without metadata
|
|
44
|
+
__version__ = "0.0.0+unknown"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"__version__",
|
|
48
|
+
# catalog seam
|
|
49
|
+
"CatalogSource", "NullCatalogSource", "DictCatalogSource",
|
|
50
|
+
"set_catalog_source", "get_catalog_source",
|
|
51
|
+
# upper-layer hooks
|
|
52
|
+
"set_budget_gate", "set_transfer_telemetry", "set_executor_registrar",
|
|
53
|
+
"set_serve_path_hook", "set_footprint_selector",
|
|
54
|
+
# events
|
|
55
|
+
"publish_catalog_changed",
|
|
56
|
+
]
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Post-download ADMISSION — the storage half (record + queue + install hook).
|
|
2
|
+
|
|
3
|
+
A model that finishes downloading on central is not yet trusted to serve. The
|
|
4
|
+
admission gate (``hugpy_ops.admission``) runs, in order, the static integrity
|
|
5
|
+
audit, then the HugPy-native benchmark, and writes the verdict onto the
|
|
6
|
+
model's own record:
|
|
7
|
+
|
|
8
|
+
hugpy.json["admission"] = {
|
|
9
|
+
"status": "pending" | "admitted" | "held",
|
|
10
|
+
"reason": str | None, # why held / the note when admitted
|
|
11
|
+
"integrity": str | None, # the static audit verdict
|
|
12
|
+
"grade": float | None, # aptitude grade the benchmark produced
|
|
13
|
+
"at": iso-8601 UTC,
|
|
14
|
+
"job": str | None, # admission job id (this module's queue)
|
|
15
|
+
# optional (present only when recorded):
|
|
16
|
+
"failure_class": str, # the benchmark row's class (no_lane, timeout, ...)
|
|
17
|
+
"evidence": dict, # that row's evidence, verbatim
|
|
18
|
+
"blocked_on": str, # no_lane: placement | eligible_worker | lane
|
|
19
|
+
"elapsed_s": float, # wall seconds the admission attempt took
|
|
20
|
+
"failures": list, # every failed row {failure_class, worker, quant, reason}
|
|
21
|
+
"run_id": str, # benchmark run, when no row named the model
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
hugpy.json is the model's record, so the admission lives there — one source.
|
|
25
|
+
Central's resolver reads it (through the persisted marker aspect every catalog
|
|
26
|
+
surface already reads) and refuses a ``held`` model; ``pending`` routes, so the
|
|
27
|
+
benchmark that decides admission can reach the model.
|
|
28
|
+
|
|
29
|
+
This module owns the parts that have to live BELOW the engine:
|
|
30
|
+
|
|
31
|
+
* :func:`write_admission` / :func:`read_admission` — atomic read-modify-write of
|
|
32
|
+
the block on an existing marker (the marker writer's own ``_save_marker``).
|
|
33
|
+
* :class:`AdmissionQueue` — a small persistent queue (sqlite in PROJECTS_HOME)
|
|
34
|
+
shared by every process on central. The download daemon's transfer child
|
|
35
|
+
enqueues; the server's admission runner (one per host, flock-elected)
|
|
36
|
+
claims. Idempotent per ``(model_key, captured_at)`` — a re-stamp that
|
|
37
|
+
carries the install manifest over never re-admits.
|
|
38
|
+
* :func:`on_install_complete` — THE hook, called by every download path that
|
|
39
|
+
writes a ``source: download`` marker, after the dir is promoted. Central
|
|
40
|
+
only (a worker never downloads from the Hub and never admits). Never raises.
|
|
41
|
+
|
|
42
|
+
No network, ever: stdlib + the marker module.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
import logging
|
|
48
|
+
import os
|
|
49
|
+
import sqlite3
|
|
50
|
+
import time
|
|
51
|
+
import uuid
|
|
52
|
+
from contextlib import contextmanager
|
|
53
|
+
from datetime import datetime, timezone
|
|
54
|
+
from typing import Any, Optional
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
ADMISSION_KEY = "admission"
|
|
59
|
+
PENDING, ADMITTED, HELD = "pending", "admitted", "held"
|
|
60
|
+
STATUSES = (PENDING, ADMITTED, HELD)
|
|
61
|
+
|
|
62
|
+
# Queue row states (the JOB, not the model's admission).
|
|
63
|
+
Q_QUEUED, Q_RUNNING, Q_DONE, Q_FAILED = "queued", "running", "done", "failed"
|
|
64
|
+
|
|
65
|
+
DB_ENV = "HUGPY_ADMISSION_DB"
|
|
66
|
+
OFF_ENV = "HUGPY_ADMISSION" # "off" disables the install hook
|
|
67
|
+
DB_BASENAME = "admission_queue.sqlite"
|
|
68
|
+
MAX_ATTEMPTS = 3 # restarts a job may survive before it is failed
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def utc_now_iso() -> str:
|
|
72
|
+
return datetime.now(timezone.utc).isoformat()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def admission_record(status: str, *, reason: Optional[str] = None,
|
|
76
|
+
integrity: Optional[str] = None, grade: Optional[float] = None,
|
|
77
|
+
job: Optional[str] = None, at: Optional[str] = None,
|
|
78
|
+
**extra: Any) -> dict:
|
|
79
|
+
"""The admission block, schema-complete (every key present)."""
|
|
80
|
+
if status not in STATUSES:
|
|
81
|
+
raise ValueError(f"admission status must be one of {STATUSES}, not {status!r}")
|
|
82
|
+
rec = {"status": status, "reason": reason, "integrity": integrity,
|
|
83
|
+
"grade": None if grade is None else float(grade),
|
|
84
|
+
"at": at or utc_now_iso(), "job": job}
|
|
85
|
+
rec.update({k: v for k, v in extra.items() if v is not None})
|
|
86
|
+
return rec
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_admission(directory: Optional[str]) -> Optional[dict]:
|
|
90
|
+
"""The admission block on ``directory``'s hugpy.json, or None."""
|
|
91
|
+
if not directory:
|
|
92
|
+
return None
|
|
93
|
+
from hugpy_storage.hugpy_marker import read_hugpy_marker
|
|
94
|
+
marker = read_hugpy_marker(directory)
|
|
95
|
+
block = (marker or {}).get(ADMISSION_KEY)
|
|
96
|
+
return block if isinstance(block, dict) else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def write_admission(directory: Optional[str], record: dict, *,
|
|
100
|
+
model_key: Optional[str] = None) -> Optional[str]:
|
|
101
|
+
"""Set the admission block on an EXISTING hugpy.json (atomic temp+replace).
|
|
102
|
+
|
|
103
|
+
Returns the marker path, or None when there is no marker to extend (the
|
|
104
|
+
record then lives only on the queue row — a dir without a marker is not
|
|
105
|
+
an installed model). ``model_key`` drops that model's persisted physical
|
|
106
|
+
record so every catalog surface re-reads the marker on its next look."""
|
|
107
|
+
if not directory:
|
|
108
|
+
return None
|
|
109
|
+
from hugpy_storage.hugpy_marker import _save_marker, read_hugpy_marker
|
|
110
|
+
marker = read_hugpy_marker(directory)
|
|
111
|
+
if not isinstance(marker, dict):
|
|
112
|
+
return None
|
|
113
|
+
marker[ADMISSION_KEY] = dict(record)
|
|
114
|
+
path = _save_marker(directory, marker)
|
|
115
|
+
if model_key:
|
|
116
|
+
try:
|
|
117
|
+
from hugpy_storage.model_physical import forget_physical
|
|
118
|
+
forget_physical(model_key, f"admission {record.get('status')}")
|
|
119
|
+
except Exception: # noqa: BLE001 — the record landed; the cache re-derives
|
|
120
|
+
logger.debug("admission: physical-record invalidation failed for %s",
|
|
121
|
+
model_key, exc_info=True)
|
|
122
|
+
return path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def held_reason(block: Optional[dict]) -> Optional[str]:
|
|
126
|
+
"""The refusal text for a ``held`` block, else None."""
|
|
127
|
+
if not isinstance(block, dict) or block.get("status") != HELD:
|
|
128
|
+
return None
|
|
129
|
+
return str(block.get("reason") or "held by admission (no reason recorded)")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── the queue ────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def default_db_path() -> str:
|
|
135
|
+
env = (os.environ.get(DB_ENV) or "").strip()
|
|
136
|
+
if env:
|
|
137
|
+
return env
|
|
138
|
+
from hugpy_platform.constants import PROJECTS_HOME
|
|
139
|
+
return os.path.join(str(PROJECTS_HOME), DB_BASENAME)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_SCHEMA = """
|
|
143
|
+
CREATE TABLE IF NOT EXISTS admission_jobs (
|
|
144
|
+
id TEXT PRIMARY KEY,
|
|
145
|
+
model_key TEXT NOT NULL,
|
|
146
|
+
directory TEXT,
|
|
147
|
+
captured_at TEXT NOT NULL,
|
|
148
|
+
source TEXT,
|
|
149
|
+
status TEXT NOT NULL,
|
|
150
|
+
owner TEXT,
|
|
151
|
+
created_at REAL NOT NULL,
|
|
152
|
+
started_at REAL,
|
|
153
|
+
finished_at REAL,
|
|
154
|
+
result TEXT,
|
|
155
|
+
log TEXT,
|
|
156
|
+
attempt INTEGER NOT NULL DEFAULT 0,
|
|
157
|
+
UNIQUE (model_key, captured_at)
|
|
158
|
+
);
|
|
159
|
+
CREATE INDEX IF NOT EXISTS admission_jobs_status ON admission_jobs (status, created_at);
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class AdmissionQueue:
|
|
164
|
+
"""Persistent, cross-process admission job queue (sqlite, WAL)."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, path: Optional[str] = None) -> None:
|
|
167
|
+
self._path = path
|
|
168
|
+
self._ready = False
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def path(self) -> str:
|
|
172
|
+
return self._path or default_db_path()
|
|
173
|
+
|
|
174
|
+
def _connect(self) -> sqlite3.Connection:
|
|
175
|
+
parent = os.path.dirname(self.path)
|
|
176
|
+
if parent:
|
|
177
|
+
os.makedirs(parent, exist_ok=True)
|
|
178
|
+
conn = sqlite3.connect(self.path, timeout=30, isolation_level=None)
|
|
179
|
+
conn.row_factory = sqlite3.Row
|
|
180
|
+
# busy_timeout makes EVERY statement (pragmas included) wait for a lock
|
|
181
|
+
# instead of failing at once; the connect() timeout alone does not cover
|
|
182
|
+
# them. Live incident 2026-09-23: the install hook lost dreamshaper-8's
|
|
183
|
+
# job with "database is locked" because the console's list() and the
|
|
184
|
+
# hook opened the file together and each ran the WAL switch + schema.
|
|
185
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
186
|
+
if not self._ready:
|
|
187
|
+
# Switching the journal mode needs an exclusive lock and the schema
|
|
188
|
+
# script is a write transaction; do both ONCE per process, and never
|
|
189
|
+
# let a lost race here break a caller that only wants to read/enqueue.
|
|
190
|
+
try:
|
|
191
|
+
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
|
192
|
+
if str(mode).lower() != "wal":
|
|
193
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
194
|
+
conn.executescript(_SCHEMA)
|
|
195
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(admission_jobs)")}
|
|
196
|
+
if "attempt" not in cols: # queues created before 2026-09-23 restart-survival
|
|
197
|
+
conn.execute("ALTER TABLE admission_jobs ADD COLUMN attempt "
|
|
198
|
+
"INTEGER NOT NULL DEFAULT 0")
|
|
199
|
+
self._ready = True
|
|
200
|
+
except sqlite3.OperationalError as exc:
|
|
201
|
+
if "locked" not in str(exc).lower():
|
|
202
|
+
raise
|
|
203
|
+
return conn
|
|
204
|
+
|
|
205
|
+
@contextmanager
|
|
206
|
+
def _db(self):
|
|
207
|
+
conn = self._connect()
|
|
208
|
+
try:
|
|
209
|
+
yield conn
|
|
210
|
+
finally:
|
|
211
|
+
conn.close()
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _row(r: Optional[sqlite3.Row]) -> Optional[dict]:
|
|
215
|
+
if r is None:
|
|
216
|
+
return None
|
|
217
|
+
d = dict(r)
|
|
218
|
+
for k in ("result",):
|
|
219
|
+
if d.get(k):
|
|
220
|
+
try:
|
|
221
|
+
d[k] = json.loads(d[k])
|
|
222
|
+
except ValueError:
|
|
223
|
+
pass
|
|
224
|
+
return d
|
|
225
|
+
|
|
226
|
+
def enqueue(self, model_key: str, captured_at: str, *,
|
|
227
|
+
directory: Optional[str] = None, source: str = "download") -> tuple:
|
|
228
|
+
"""``(job_row, created)``. A second enqueue of the same
|
|
229
|
+
``(model_key, captured_at)`` returns the existing row, created=False."""
|
|
230
|
+
if not model_key or not captured_at:
|
|
231
|
+
raise ValueError("model_key and captured_at are required")
|
|
232
|
+
job_id = uuid.uuid4().hex[:16]
|
|
233
|
+
with self._db() as conn:
|
|
234
|
+
cur = conn.execute(
|
|
235
|
+
"INSERT OR IGNORE INTO admission_jobs (id, model_key, directory, "
|
|
236
|
+
"captured_at, source, status, created_at) VALUES (?,?,?,?,?,?,?)",
|
|
237
|
+
(job_id, model_key, directory, str(captured_at), source,
|
|
238
|
+
Q_QUEUED, time.time()))
|
|
239
|
+
created = cur.rowcount == 1
|
|
240
|
+
row = conn.execute(
|
|
241
|
+
"SELECT * FROM admission_jobs WHERE model_key=? AND captured_at=?",
|
|
242
|
+
(model_key, str(captured_at))).fetchone()
|
|
243
|
+
return self._row(row), created
|
|
244
|
+
|
|
245
|
+
def claim_next(self, owner: str) -> Optional[dict]:
|
|
246
|
+
"""Atomically take the oldest queued job (compare-and-set)."""
|
|
247
|
+
with self._db() as conn:
|
|
248
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
249
|
+
try:
|
|
250
|
+
row = conn.execute(
|
|
251
|
+
"SELECT * FROM admission_jobs WHERE status=? "
|
|
252
|
+
"ORDER BY created_at LIMIT 1", (Q_QUEUED,)).fetchone()
|
|
253
|
+
if row is None:
|
|
254
|
+
conn.execute("COMMIT")
|
|
255
|
+
return None
|
|
256
|
+
conn.execute(
|
|
257
|
+
"UPDATE admission_jobs SET status=?, owner=?, started_at=? "
|
|
258
|
+
"WHERE id=? AND status=?",
|
|
259
|
+
(Q_RUNNING, owner, time.time(), row["id"], Q_QUEUED))
|
|
260
|
+
conn.execute("COMMIT")
|
|
261
|
+
except BaseException:
|
|
262
|
+
conn.execute("ROLLBACK")
|
|
263
|
+
raise
|
|
264
|
+
return self.get(row["id"])
|
|
265
|
+
|
|
266
|
+
def requeue_stale(self, older_than_s: float = 6 * 3600) -> int:
|
|
267
|
+
"""Put jobs a dead runner left ``running`` back on the queue."""
|
|
268
|
+
cutoff = time.time() - older_than_s
|
|
269
|
+
with self._db() as conn:
|
|
270
|
+
cur = conn.execute(
|
|
271
|
+
"UPDATE admission_jobs SET status=?, owner=NULL WHERE status=? "
|
|
272
|
+
"AND started_at < ?", (Q_QUEUED, Q_RUNNING, cutoff))
|
|
273
|
+
return cur.rowcount
|
|
274
|
+
|
|
275
|
+
def requeue_orphaned(self, reason: str = "central restarted",
|
|
276
|
+
max_attempts: int = MAX_ATTEMPTS) -> dict:
|
|
277
|
+
"""Called by a NEWLY ELECTED runner: the runner flock is held for the
|
|
278
|
+
life of the process that runs jobs, so every row still ``running`` at
|
|
279
|
+
election belongs to a runner that died (a central restart). Each goes
|
|
280
|
+
back to ``queued`` with ``attempt + 1`` and a log line; a job already
|
|
281
|
+
interrupted ``max_attempts`` times is ``failed`` with the reason instead
|
|
282
|
+
of looping forever (the model's admission stays pending; rerun it)."""
|
|
283
|
+
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
284
|
+
out = {"requeued": [], "failed": []}
|
|
285
|
+
with self._db() as conn:
|
|
286
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
287
|
+
try:
|
|
288
|
+
rows = conn.execute("SELECT id, attempt, log FROM admission_jobs WHERE status=?",
|
|
289
|
+
(Q_RUNNING,)).fetchall()
|
|
290
|
+
for r in rows:
|
|
291
|
+
attempt = int(r["attempt"] or 0) + 1
|
|
292
|
+
prior = (r["log"] + "\n") if r["log"] else ""
|
|
293
|
+
if attempt > max_attempts:
|
|
294
|
+
line = (f"{stamp} interrupted ({reason}) {attempt - 1}x — not re-queued "
|
|
295
|
+
f"again (max {max_attempts}); POST /llm/admission/<model>/rerun")
|
|
296
|
+
conn.execute(
|
|
297
|
+
"UPDATE admission_jobs SET status=?, owner=NULL, attempt=?, finished_at=?, "
|
|
298
|
+
"result=?, log=? WHERE id=? AND status=?",
|
|
299
|
+
(Q_FAILED, attempt, time.time(),
|
|
300
|
+
json.dumps({"status": "failed", "reason": line[len(stamp) + 1:]}),
|
|
301
|
+
prior + line, r["id"], Q_RUNNING))
|
|
302
|
+
out["failed"].append(r["id"])
|
|
303
|
+
else:
|
|
304
|
+
line = f"{stamp} re-queued: {reason} while running (attempt {attempt})"
|
|
305
|
+
conn.execute(
|
|
306
|
+
"UPDATE admission_jobs SET status=?, owner=NULL, started_at=NULL, "
|
|
307
|
+
"attempt=?, log=? WHERE id=? AND status=?",
|
|
308
|
+
(Q_QUEUED, attempt, prior + line, r["id"], Q_RUNNING))
|
|
309
|
+
out["requeued"].append(r["id"])
|
|
310
|
+
conn.execute("COMMIT")
|
|
311
|
+
except BaseException:
|
|
312
|
+
conn.execute("ROLLBACK")
|
|
313
|
+
raise
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
def requeue_owner(self, owner: str) -> int:
|
|
317
|
+
"""A restarted runner re-queues what its previous incarnation held."""
|
|
318
|
+
with self._db() as conn:
|
|
319
|
+
cur = conn.execute(
|
|
320
|
+
"UPDATE admission_jobs SET status=?, owner=NULL WHERE status=? AND owner=?",
|
|
321
|
+
(Q_QUEUED, Q_RUNNING, owner))
|
|
322
|
+
return cur.rowcount
|
|
323
|
+
|
|
324
|
+
def finish(self, job_id: str, status: str, *, result: Optional[dict] = None,
|
|
325
|
+
log: Optional[list] = None) -> None:
|
|
326
|
+
with self._db() as conn:
|
|
327
|
+
conn.execute(
|
|
328
|
+
"UPDATE admission_jobs SET status=?, finished_at=?, result=?, "
|
|
329
|
+
"log=COALESCE(?, log) WHERE id=?",
|
|
330
|
+
(status, time.time(), json.dumps(result, default=str) if result is not None else None,
|
|
331
|
+
"\n".join(log) if log else None, job_id))
|
|
332
|
+
|
|
333
|
+
def append_log(self, job_id: str, line: str) -> None:
|
|
334
|
+
with self._db() as conn:
|
|
335
|
+
row = conn.execute("SELECT log FROM admission_jobs WHERE id=?", (job_id,)).fetchone()
|
|
336
|
+
prior = (row["log"] + "\n") if row and row["log"] else ""
|
|
337
|
+
conn.execute("UPDATE admission_jobs SET log=? WHERE id=?", (prior + line, job_id))
|
|
338
|
+
|
|
339
|
+
def get(self, job_id: str) -> Optional[dict]:
|
|
340
|
+
with self._db() as conn:
|
|
341
|
+
return self._row(conn.execute(
|
|
342
|
+
"SELECT * FROM admission_jobs WHERE id=?", (job_id,)).fetchone())
|
|
343
|
+
|
|
344
|
+
def latest_for(self, model_key: str) -> Optional[dict]:
|
|
345
|
+
with self._db() as conn:
|
|
346
|
+
return self._row(conn.execute(
|
|
347
|
+
"SELECT * FROM admission_jobs WHERE model_key=? ORDER BY created_at DESC LIMIT 1",
|
|
348
|
+
(model_key,)).fetchone())
|
|
349
|
+
|
|
350
|
+
def list(self, *, status: Optional[str] = None, limit: int = 500) -> list:
|
|
351
|
+
q, args = "SELECT * FROM admission_jobs", []
|
|
352
|
+
if status:
|
|
353
|
+
q += " WHERE status=?"
|
|
354
|
+
args.append(status)
|
|
355
|
+
q += " ORDER BY created_at DESC LIMIT ?"
|
|
356
|
+
args.append(int(limit))
|
|
357
|
+
with self._db() as conn:
|
|
358
|
+
return [self._row(r) for r in conn.execute(q, args).fetchall()]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
admission_queue = AdmissionQueue()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def hook_enabled() -> bool:
|
|
365
|
+
"""The install hook runs on CENTRAL only, and can be switched off."""
|
|
366
|
+
if (os.environ.get(OFF_ENV) or "").strip().lower() in ("0", "off", "false", "no"):
|
|
367
|
+
return False
|
|
368
|
+
try:
|
|
369
|
+
from hugpy_storage.provision import worker_central_url
|
|
370
|
+
return worker_central_url() is None
|
|
371
|
+
except Exception: # noqa: BLE001
|
|
372
|
+
return True
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def on_install_complete(directory: str, model_key: Optional[str] = None, *,
|
|
376
|
+
source: str = "download", captured_at: Optional[str] = None,
|
|
377
|
+
queue: Optional[AdmissionQueue] = None) -> Optional[dict]:
|
|
378
|
+
"""THE install hook: enqueue one admission job for the model now at
|
|
379
|
+
``directory`` and mark it ``pending`` on its marker.
|
|
380
|
+
|
|
381
|
+
Keys: the marker's declared ``name`` (what discovery keys the catalog on)
|
|
382
|
+
wins over the caller's ``model_key`` (a repo-download job id such as
|
|
383
|
+
``owner_repo``); the runner re-resolves against the catalog by directory.
|
|
384
|
+
``captured_at`` defaults to the marker's install-manifest capture time —
|
|
385
|
+
that is what makes the enqueue idempotent per install. Returns the job row,
|
|
386
|
+
or None when nothing was enqueued. NEVER raises: a completed download must
|
|
387
|
+
not fail over its admission bookkeeping."""
|
|
388
|
+
try:
|
|
389
|
+
if not hook_enabled():
|
|
390
|
+
return None
|
|
391
|
+
from hugpy_storage.hugpy_marker import MANIFEST_KEY, read_hugpy_marker
|
|
392
|
+
marker = read_hugpy_marker(directory) if directory else None
|
|
393
|
+
key = ((marker or {}).get("name") or model_key or "").strip()
|
|
394
|
+
if not key:
|
|
395
|
+
return None
|
|
396
|
+
stamp = captured_at or ((marker or {}).get(MANIFEST_KEY) or {}).get("captured_at") \
|
|
397
|
+
or (marker or {}).get("stamped_at")
|
|
398
|
+
if not stamp:
|
|
399
|
+
return None
|
|
400
|
+
q = queue or admission_queue
|
|
401
|
+
job, created = q.enqueue(key, str(stamp), directory=directory, source=source)
|
|
402
|
+
if created and marker is not None:
|
|
403
|
+
write_admission(directory, admission_record(
|
|
404
|
+
PENDING, reason="admission queued after download", job=job["id"]),
|
|
405
|
+
model_key=key)
|
|
406
|
+
logger.info("admission: %s job %s for %s (%s)",
|
|
407
|
+
"queued" if created else "already queued", job["id"], key, directory)
|
|
408
|
+
return job
|
|
409
|
+
except Exception as exc: # noqa: BLE001
|
|
410
|
+
logger.warning("admission hook failed for %s (%s): %s", model_key, directory, exc)
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def request_admission(model_key: str, directory: Optional[str], *,
|
|
415
|
+
source: str = "rerun", reason: str = "admission re-run requested",
|
|
416
|
+
queue: Optional[AdmissionQueue] = None) -> dict:
|
|
417
|
+
"""Queue a fresh admission job for an installed model (operator re-run,
|
|
418
|
+
resweep). Unlike the install hook this always creates a job — its
|
|
419
|
+
``captured_at`` is ``<source>:<now>`` — and it raises on failure (the
|
|
420
|
+
caller is an operator surface that must report it)."""
|
|
421
|
+
q = queue or admission_queue
|
|
422
|
+
job, _created = q.enqueue(model_key, f"{source}:{utc_now_iso()}",
|
|
423
|
+
directory=directory, source=source)
|
|
424
|
+
write_admission(directory, admission_record(PENDING, reason=reason, job=job["id"]),
|
|
425
|
+
model_key=model_key)
|
|
426
|
+
return job
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
__all__ = [
|
|
430
|
+
"ADMISSION_KEY", "PENDING", "ADMITTED", "HELD", "STATUSES",
|
|
431
|
+
"Q_QUEUED", "Q_RUNNING", "Q_DONE", "Q_FAILED",
|
|
432
|
+
"admission_record", "read_admission", "write_admission", "held_reason",
|
|
433
|
+
"AdmissionQueue", "admission_queue", "default_db_path",
|
|
434
|
+
"hook_enabled", "on_install_complete", "request_admission", "utc_now_iso",
|
|
435
|
+
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The ARCHIVE MARK — an operator's "retire this model" flag on its own record.
|
|
2
|
+
|
|
3
|
+
Two states only: LIVE or ARCHIVE (operator ruling 2026-09-23). The mark is the
|
|
4
|
+
operator's recorded intent to move a live model into the archive; the sweep
|
|
5
|
+
(``hugpy_ops.model_archive`` / ``hugpy-model-archive --apply``) is what turns
|
|
6
|
+
the intent into the archive state (the directory moved under
|
|
7
|
+
``ARCHIVE/MODELS_ARCHIVED-<date>/``, a MANIFEST row with the restore command,
|
|
8
|
+
the catalog row removed). Marking deletes, moves and evicts NOTHING.
|
|
9
|
+
|
|
10
|
+
The mark lives on the model's hugpy.json, beside ``admission``:
|
|
11
|
+
|
|
12
|
+
hugpy.json["archive"] = {
|
|
13
|
+
"marked": true,
|
|
14
|
+
"at": iso-8601 UTC, # when it was marked
|
|
15
|
+
"by": str, # who marked it (operator username / key name / 'console')
|
|
16
|
+
"reason": str | None, # the operator's words, verbatim; None when none given
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Unmarking removes the block (the marker is the record of CURRENT intent; the
|
|
20
|
+
server audit log keeps the history of mark/unmark events).
|
|
21
|
+
|
|
22
|
+
While marked, central refuses the model everywhere a placement or routing
|
|
23
|
+
choice could land on it: new designations (assign / load / bulk alloc / group
|
|
24
|
+
allocate / template activate / placement preference), warm + provisioning
|
|
25
|
+
sweeps, and the resolver (``hugpy_fleet.central.archive_gate``). Every refusal
|
|
26
|
+
carries :func:`archive_text` — the recorded facts, never a canned line.
|
|
27
|
+
|
|
28
|
+
stdlib + the marker module; no network.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
ARCHIVE_KEY = "archive"
|
|
39
|
+
# The phrase every refusal carries (the engine's cold-hold classifier keys on
|
|
40
|
+
# it as PERMANENT: fail fast, never held/retried, never cached as a load verdict).
|
|
41
|
+
ARCHIVE_MARKER = "marked for archive"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def utc_now_iso() -> str:
|
|
45
|
+
return datetime.now(timezone.utc).isoformat()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def archive_record(*, by: str, reason: Optional[str] = None,
|
|
49
|
+
at: Optional[str] = None) -> dict:
|
|
50
|
+
"""The archive block, schema-complete (every key present)."""
|
|
51
|
+
r = (reason or "").strip() if isinstance(reason, str) else None
|
|
52
|
+
return {"marked": True, "at": at or utc_now_iso(),
|
|
53
|
+
"by": str(by or "").strip() or "console", "reason": r or None}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_marked(block: Optional[dict]) -> bool:
|
|
57
|
+
return isinstance(block, dict) and bool(block.get("marked"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def archive_view(block: Optional[dict]) -> dict:
|
|
61
|
+
"""The ``archived`` projection every surface exposes:
|
|
62
|
+
``{marked, at, by, reason}`` (``marked: false`` + nulls when unmarked)."""
|
|
63
|
+
if not is_marked(block):
|
|
64
|
+
return {"marked": False, "at": None, "by": None, "reason": None}
|
|
65
|
+
return {"marked": True, "at": block.get("at"), "by": block.get("by"),
|
|
66
|
+
"reason": block.get("reason")}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def archive_text(block: Optional[dict]) -> Optional[str]:
|
|
70
|
+
"""``marked for archive by <by> at <at>: <reason>`` from the recorded block,
|
|
71
|
+
or None when not marked. A mark recorded without a reason says so."""
|
|
72
|
+
if not is_marked(block):
|
|
73
|
+
return None
|
|
74
|
+
reason = block.get("reason")
|
|
75
|
+
tail = f": {reason}" if reason else " (no reason was given when it was marked)"
|
|
76
|
+
return f"{ARCHIVE_MARKER} by {block.get('by')} at {block.get('at')}{tail}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_archive_mark(directory: Optional[str]) -> Optional[dict]:
|
|
80
|
+
"""The archive block on ``directory``'s hugpy.json, or None."""
|
|
81
|
+
if not directory:
|
|
82
|
+
return None
|
|
83
|
+
from hugpy_storage.hugpy_marker import read_hugpy_marker
|
|
84
|
+
block = (read_hugpy_marker(directory) or {}).get(ARCHIVE_KEY)
|
|
85
|
+
return block if isinstance(block, dict) else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _forget(model_key: Optional[str], why: str) -> None:
|
|
89
|
+
if not model_key:
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
from hugpy_storage.model_physical import forget_physical
|
|
93
|
+
forget_physical(model_key, why)
|
|
94
|
+
except Exception: # noqa: BLE001 — the record landed; the cache re-derives
|
|
95
|
+
logger.debug("archive mark: physical-record invalidation failed for %s",
|
|
96
|
+
model_key, exc_info=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def write_archive_mark(directory: Optional[str], record: dict, *,
|
|
100
|
+
model_key: Optional[str] = None) -> Optional[str]:
|
|
101
|
+
"""Set the archive block on an EXISTING hugpy.json (atomic temp+replace).
|
|
102
|
+
|
|
103
|
+
Returns the marker path, or None when the dir carries no marker (a dir
|
|
104
|
+
without a marker is not an installed model — nothing to mark). Drops the
|
|
105
|
+
model's persisted physical record so every catalog surface re-reads it."""
|
|
106
|
+
if not directory:
|
|
107
|
+
return None
|
|
108
|
+
from hugpy_storage.hugpy_marker import _save_marker, read_hugpy_marker
|
|
109
|
+
marker = read_hugpy_marker(directory)
|
|
110
|
+
if not isinstance(marker, dict):
|
|
111
|
+
return None
|
|
112
|
+
marker[ARCHIVE_KEY] = dict(record)
|
|
113
|
+
path = _save_marker(directory, marker)
|
|
114
|
+
_forget(model_key, f"archive marked by {record.get('by')}")
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def clear_archive_mark(directory: Optional[str], *,
|
|
119
|
+
model_key: Optional[str] = None) -> Optional[dict]:
|
|
120
|
+
"""Remove the archive block. Returns the block that was removed, or None
|
|
121
|
+
when there was none (or no marker)."""
|
|
122
|
+
if not directory:
|
|
123
|
+
return None
|
|
124
|
+
from hugpy_storage.hugpy_marker import _save_marker, read_hugpy_marker
|
|
125
|
+
marker = read_hugpy_marker(directory)
|
|
126
|
+
if not isinstance(marker, dict) or ARCHIVE_KEY not in marker:
|
|
127
|
+
return None
|
|
128
|
+
was = marker.pop(ARCHIVE_KEY)
|
|
129
|
+
_save_marker(directory, marker)
|
|
130
|
+
_forget(model_key, "archive mark cleared")
|
|
131
|
+
return was if isinstance(was, dict) else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["ARCHIVE_KEY", "ARCHIVE_MARKER", "archive_record", "archive_text",
|
|
135
|
+
"archive_view", "clear_archive_mark", "is_marked", "read_archive_mark",
|
|
136
|
+
"write_archive_mark"]
|