Versioned artifact registry¶
Some services serve binaries that swap at runtime: ONNX models, rule bundles, compiled config files. The shape is always the same — one logical artifact (name) has many versions, and exactly one is "current" at a time. An operator uploads a new version to object storage and activates it from the admin panel; the endpoints resolve the current version per request, with no redeploy.
The tempest_fastapi_sdk.artifacts module ships the generic core of that pattern:
ArtifactVersionMixin— the(name, version, file_key, is_current)columns.ArtifactRegistry— resolve the current version, list the current ones, activate one (one current pername).file_digest/object_digest— sha256 + size, streamed and memoized by the immutable identity.build_manifest_entries+ArtifactManifestEntry— a serialization-agnostic manifest.make_activate_artifact_action— the admin action that activates the selected version.
What the SDK does NOT decide for you
The SDK stays generic. The domain (.onnx names, per-model input_size, the exact manifest shape your PWA consumes, the download URL scheme) lives in your service — the registry just hands you the pieces.
Installation¶
Object digesting and serving bytes use the MinIO client, shipped in the [minio] extra:
Everything else (mixin, registry, file digest, admin action) runs on the core only.
1. The table¶
Mix ArtifactVersionMixin into a concrete BaseModel table. is_current is distinct from is_active (BaseModel's soft-delete flag): a version can be active (not deleted) and still not be the one served.
from tempest_fastapi_sdk import BaseModel
from tempest_fastapi_sdk.artifacts import ArtifactVersionMixin
class ModelVersion(BaseModel, ArtifactVersionMixin):
"""One version of an ONNX model served at runtime."""
__tablename__ = "model_versions"
That's it — you inherit id / is_active / created_at / updated_at from BaseModel and name / version / file_key / is_current from the mixin.
Checksums are not columns
Don't store sha256/size on the table. They derive from the immutable object in storage and are computed on demand (and memoized by file_key). Storing them would duplicate the truth and invite drift.
2. The registry¶
ArtifactRegistry takes a BaseRepository bound to your table and, optionally, the MinIO client + bucket.
from tempest_fastapi_sdk import AsyncMinIOClient, BaseRepository
from tempest_fastapi_sdk.artifacts import ArtifactRegistry
from src.db.models import ModelVersion
def build_registry(session, storage: AsyncMinIOClient) -> ArtifactRegistry[ModelVersion]:
"""Assemble the registry for the model-version table."""
repository: BaseRepository[ModelVersion] = BaseRepository(session, model=ModelVersion)
return ArtifactRegistry(repository, minio=storage, bucket="models")
The three operations:
import asyncio
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import AsyncMinIOClient
from src.core.settings import settings
from src.db.repositories import build_registry
# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
registry = build_registry(session, AsyncMinIOClient(**settings.minio_kwargs()))
version_id = UUID("6f1c3d84-2a55-4d0b-9d7e-0c1a2b3c4d5e")
async def main() -> None:
"""Run this example."""
# The served version of "detect" (or None when none has been activated yet).
current = await registry.current("detect")
# The current version of every artifact (one current per name).
rows = await registry.list_current()
# Activate a version: flip is_current on it and clear it on the same-name
# siblings, all in one transaction.
activated = await registry.activate(version_id)
asyncio.run(main())
One current per name, enforced on write
activate runs an UPDATE ... SET is_current=False WHERE name=<name> then flips the flag on the target row, in the same commit. There's no DB constraint — the invariant is kept by the action, exactly like the reference service.
3. The digest (streamed + memoized)¶
Both helpers return (sha256, size) without loading the whole file into memory (1 MiB chunks) and memoize the result, because the identity is immutable:
import asyncio
from tempest_fastapi_sdk import AsyncMinIOClient
from src.core.settings import settings
from tempest_fastapi_sdk.artifacts import file_digest, object_digest
storage = AsyncMinIOClient(**settings.minio_kwargs())
async def main() -> None:
"""Run this example."""
# On-disk file (the bundled fallback), cached by path:
sha256, size = await file_digest("/opt/models/detect.onnx")
# Object in MinIO, cached by (bucket, key):
sha256, size = await object_digest(storage, "models", "detect/1.2.0.onnx")
asyncio.run(main())
4. The manifest¶
build_manifest_entries walks the current versions and asks a digest_source you provide for each row's (sha256, size) — that's where you decide where the bytes come from (MinIO for active versions, disk for the bundled fallback).
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import AsyncMinIOClient
from src.core.settings import settings
from tempest_fastapi_sdk.artifacts import (
ArtifactManifestEntry,
build_manifest_entries,
object_digest,
)
from src.db.models import ModelVersion
from src.db.repositories import build_registry
# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
registry = build_registry(session, AsyncMinIOClient(**settings.minio_kwargs()))
storage = AsyncMinIOClient(**settings.minio_kwargs())
async def main() -> None:
"""Run this example."""
async def model_digest(row: ModelVersion) -> tuple[str, int]:
"""Digest the current version from its MinIO object."""
return await object_digest(storage, "models", row.file_key)
entries: list[ArtifactManifestEntry] = await build_manifest_entries(
registry, digest_source=model_digest
)
asyncio.run(main())
Each ArtifactManifestEntry carries name, version, file_key, sha256, size. The final envelope (download URL, global manifest version, domain fields like input_size) is yours to assemble on top:
from fastapi import APIRouter
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import AsyncMinIOClient
from src.core.settings import settings
from tempest_fastapi_sdk.artifacts import build_manifest_entries, object_digest
from src.db.models import ModelVersion
from src.db.repositories import build_registry
# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
async def model_digest(row: ModelVersion) -> tuple[str, int]:
"""Return the (sha256, size) of the object behind the active version."""
return await object_digest(storage, "models", row.file_key)
registry = build_registry(session, AsyncMinIOClient(**settings.minio_kwargs()))
storage = AsyncMinIOClient(**settings.minio_kwargs())
router = APIRouter(prefix="/models", tags=["models"])
class ModelManifest(BaseModel):
"""Manifest shape your PWA consumes."""
models: list[dict]
@router.get("/manifest")
async def manifest() -> ModelManifest:
"""Manifest the client polls to detect newer versions."""
entries = await build_manifest_entries(registry, digest_source=model_digest)
return ModelManifest(
models=[
{
"name": e.name,
"url": f"/models/{e.name}.onnx",
"sha256": e.sha256,
"size": e.size,
}
for e in entries
]
)
5. The "Activate version" admin action¶
make_activate_artifact_action returns an AdminAction whose handler activates the selected row (clearing same-name siblings). The handler carries the @admin_action marker, so register it by passing action.handler:
from tempest_fastapi_sdk import AdminModel
from tempest_fastapi_sdk.artifacts import make_activate_artifact_action
from src.admin import site
from src.db.models import ModelVersion
activate = make_activate_artifact_action(label="Activate version")
site.register(AdminModel(model=ModelVersion, actions=[activate.handler]))
Select one version in the list, choose "Activate version" — the panel flips is_current on it and clears it on the others of the same model. Selecting zero or many returns a warning.
Why pass action.handler
AdminModel(actions=[...]) expects functions decorated with @admin_action (it reads the __admin_action__ marker). The factory returns the AdminAction, and .handler is the decorated function — so action.handler is exactly what AdminModel knows how to register.
6. Serving the bytes (with fallback)¶
Resolve the active version; if it exists, stream it from MinIO; otherwise serve the bundled on-disk file.
from fastapi import APIRouter
from fastapi.responses import FileResponse
from starlette.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import AsyncMinIOClient
from src.core.settings import settings
from src.db.repositories import build_registry
# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
registry = build_registry(session, AsyncMinIOClient(**settings.minio_kwargs()))
storage = AsyncMinIOClient(**settings.minio_kwargs())
router = APIRouter()
@router.get("/{name}.onnx", response_model=None)
async def download(name: str) -> StreamingResponse | FileResponse:
"""Serve the active version from MinIO, or the bundled file."""
active = await registry.current(name)
if active is not None:
return await storage.download_response(
active.file_key,
bucket="models",
media_type="application/octet-stream",
filename=f"{name}.onnx",
)
return FileResponse(f"/opt/models/{name}.onnx", filename=f"{name}.onnx")
Recap¶
- Mix
ArtifactVersionMixininto aBaseModeltable →name/version/file_key/is_currentcolumns. ArtifactRegistrygivescurrent/list_current/activate(one current pername, in one transaction).file_digest/object_digeststream the sha256 + size and memoize by the immutable identity.build_manifest_entriesbuilds agnostic entries; you supply thedigest_sourceand the final envelope.make_activate_artifact_actionis the activation admin action — registeraction.handler.
The generic pattern comes from the SDK; the domain details (file names, input_size, the PWA manifest shape) stay in your service. 🚀