Registro de artefatos versionados¶
Alguns serviços servem binários que trocam em runtime: modelos ONNX, bundles de regras, arquivos de configuração compilados. O padrão é sempre o mesmo — um artefato lógico (name) tem várias versões, e exatamente uma está "ativa" por vez. Um operador sobe uma versão nova no object storage e a ativa pelo painel admin; os endpoints resolvem a versão ativa a cada request, sem redeploy.
O módulo tempest_fastapi_sdk.artifacts entrega o núcleo genérico desse padrão:
ArtifactVersionMixin— as colunas(name, version, file_key, is_current).ArtifactRegistry— resolver a versão atual, listar as atuais, ativar uma (uma current porname).file_digest/object_digest— sha256 + tamanho, em streaming e memoizados pela identidade imutável.build_manifest_entries+ArtifactManifestEntry— um manifesto agnóstico de serialização.make_activate_artifact_action— a ação admin que ativa a versão selecionada.
O que o SDK NÃO decide por você
O SDK fica no genérico. O domínio (nomes .onnx, input_size por modelo, o formato exato do manifesto que o PWA consome, o esquema de URL de download) mora no seu serviço — o registry só te dá as peças.
Instalação¶
O digest de objetos e servir bytes usa o cliente MinIO, que vem no extra [minio]:
O resto (mixin, registry, digest de arquivo, ação admin) roda só com o core.
1. A tabela¶
Misture ArtifactVersionMixin numa tabela BaseModel concreta. is_current é distinto de is_active (o flag de soft-delete do BaseModel): uma versão pode estar ativa (não deletada) e mesmo assim não ser a servida.
from tempest_fastapi_sdk import BaseModel
from tempest_fastapi_sdk.artifacts import ArtifactVersionMixin
class ModelVersion(BaseModel, ArtifactVersionMixin):
"""Uma versão de um modelo ONNX servido em runtime."""
__tablename__ = "model_versions"
Pronto — você herda id / is_active / created_at / updated_at do BaseModel e name / version / file_key / is_current do mixin.
Checksums não são colunas
Não guarde sha256/size na tabela. Eles derivam do objeto imutável no storage e são calculados sob demanda (e memoizados pelo file_key). Guardar duplicaria a verdade e abriria espaço pra divergência.
2. O registry¶
ArtifactRegistry recebe um BaseRepository ligado à sua tabela e, opcionalmente, o cliente MinIO + 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]:
"""Monta o registry para a tabela de versões de modelo."""
repository: BaseRepository[ModelVersion] = BaseRepository(session, model=ModelVersion)
return ArtifactRegistry(repository, minio=storage, bucket="models")
As três operações:
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
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do 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."""
# A versão servida de "detect" (ou None quando nenhuma foi ativada ainda).
current = await registry.current("detect")
# A versão atual de cada artefato (uma current por name).
rows = await registry.list_current()
# Ativa uma versão: liga is_current nela e desliga nos irmãos de mesmo name,
# tudo numa transação só.
activated = await registry.activate(version_id)
asyncio.run(main())
Uma current por name, garantida na escrita
activate faz um UPDATE ... SET is_current=False WHERE name=<name> seguido de ligar o flag na linha alvo, no mesmo commit. Não há constraint de banco — a invariante é mantida pela ação, exatamente como no serviço de referência.
3. O digest (streaming + memoizado)¶
Os dois helpers devolvem (sha256, size) sem carregar o arquivo inteiro na memória (chunks de 1 MiB) e memoizam o resultado, porque a identidade é imutável:
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."""
# Arquivo em disco (o fallback empacotado), cacheado pelo caminho:
sha256, size = await file_digest("/opt/models/detect.onnx")
# Objeto no MinIO, cacheado por (bucket, key):
sha256, size = await object_digest(storage, "models", "detect/1.2.0.onnx")
asyncio.run(main())
4. O manifesto¶
build_manifest_entries percorre as versões atuais e pede o (sha256, size) de cada uma a um digest_source que você fornece — é aí que você decide de onde vêm os bytes (MinIO para versões ativas, disco para o fallback empacotado).
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
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do 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 da versão atual a partir do objeto no MinIO."""
return await object_digest(storage, "models", row.file_key)
entries: list[ArtifactManifestEntry] = await build_manifest_entries(
registry, digest_source=model_digest
)
asyncio.run(main())
Cada ArtifactManifestEntry tem name, version, file_key, sha256, size. O envelope final (URL de download, versão global do manifesto, campos de domínio como input_size) você monta por cima:
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
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do 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):
"""Formato de manifesto que o seu PWA consome."""
models: list[dict]
@router.get("/manifest")
async def manifest() -> ModelManifest:
"""Manifesto que o cliente consulta pra detectar versões novas."""
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. A ação admin "Ativar versão"¶
make_activate_artifact_action devolve uma AdminAction cujo handler ativa a linha selecionada (limpando os irmãos de mesmo name). O handler carrega o marcador @admin_action, então você registra passando 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="Ativar versão")
site.register(AdminModel(model=ModelVersion, actions=[activate.handler]))
Selecione uma versão na lista, escolha "Ativar versão" — o painel liga is_current nela e desliga nas outras do mesmo modelo. Selecionar zero ou várias devolve um aviso.
Por que passar action.handler
AdminModel(actions=[...]) espera funções decoradas com @admin_action (ele lê o marcador __admin_action__). A fábrica devolve a AdminAction, e o .handler é a função decorada — então action.handler é exatamente o que o AdminModel sabe registrar.
6. Servir os bytes (com fallback)¶
Resolva a versão ativa; se existir, faça streaming do MinIO; senão, sirva o arquivo empacotado em disco.
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
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do 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 a versão ativa do MinIO, ou o arquivo empacotado."""
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¶
- Misture
ArtifactVersionMixinnuma tabelaBaseModel→ colunasname/version/file_key/is_current. ArtifactRegistrydácurrent/list_current/activate(uma current porname, numa transação).file_digest/object_digeststreamam o sha256 + tamanho e memoizam pela identidade imutável.build_manifest_entriesmonta entradas agnósticas; você fornece odigest_sourcee o envelope final.make_activate_artifact_actioné a ação admin de ativação — registreaction.handler.
O padrão genérico vem do SDK; os detalhes de domínio (nomes de arquivo, input_size, o shape do manifesto do PWA) ficam no seu serviço. 🚀