Spaces:
Running on L4
Running on L4
Indexing main 955a553a: usdsearch fd61a332 packages, package downloads, usdsearch validator
Browse filesFiles deploy-space(-gpu) stages from NVIDIA-dev/simready-central-indexing-internal main 955a553a (PRs #39, #41, #43); same run 20261005-ee37df6; Space secrets unchanged.
- Dockerfile +14 -6
- THIRD_PARTY_NOTICE.md +5 -6
- docker/hf-space/deeptag-validate/app.py +0 -836
- docker/hf-space/hf-storage-api/SOURCE.txt +2 -2
- docker/hf-space/hf-storage-api/constraints-hf-storage-api.txt +3 -3
- docker/hf-space/hf-storage-api/hf_storage_api/addressing.py +15 -1
- docker/hf-space/hf-storage-api/hf_storage_api/catalog/loader.py +137 -43
- docker/hf-space/hf-storage-api/hf_storage_api/catalog/model.py +13 -1
- docker/hf-space/hf-storage-api/hf_storage_api/catalog/urllist.py +3 -12
- docker/hf-space/hf-storage-api/hf_storage_api/catalog_sync/publish.py +3 -2
- docker/hf-space/hf-storage-api/hf_storage_api/hf/client.py +15 -2
- docker/hf-space/hf-storage-api/hf_storage_api/services/capabilities.py +6 -5
- docker/hf-space/hf-storage-api/hf_storage_api/services/filefolder.py +7 -6
- docker/hf-space/hf-storage-api/hf_storage_api/services/fileobject.py +33 -15
- docker/hf-space/hf-storage-api/hf_storage_api/services/metadata_service.py +7 -1
- docker/hf-space/hf-storage-api/hf_storage_api/settings.py +9 -0
- docker/hf-space/hf-storage-api/hf_storage_api/store.py +83 -32
- docker/hf-space/nginx.conf.template +13 -10
- docker/hf-space/siglip2-model/SOURCE.txt +3 -3
- docker/hf-space/siglip2-model/pyproject.toml +1 -0
- docker/hf-space/siglip2-model/uv.lock +5 -3
- docker/hf-space/supervisord-gpu.conf +16 -20
- docker/hf-space/supervisord.conf +34 -24
- docker/hf-space/wheels/SOURCE.txt +2 -2
- docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl +0 -0
- docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl +0 -0
- docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl +0 -0
- docker/hf-space/wheels/constraints.txt +2 -2
- docker/hf-space/wheels/deepsearch_api-0.1.0-py3-none-any.whl +2 -2
- docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl +0 -0
- docker/hf-space/wheels/search_utils-0.1.0-py3-none-any.whl +2 -2
- docker/hf-space/wheels/siglip2_triton_client-0.6.1-py3-none-any.whl +2 -2
- docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl +0 -0
- licenses/fsspec-2026.4.0.txt +0 -29
- licenses/{fsspec-2026.3.0.txt → fsspec-2026.9.0.txt} +0 -0
- licenses/{langgraph-sdk-0.4.2.txt → langgraph-sdk-0.4.6.txt} +0 -0
- licenses/{os-redis-server-5-7.0.15-1ubuntu0.24.04.4.txt → os-redis-server-5-7.0.15-1ubuntu0.24.04.5.txt} +0 -0
- licenses/{os-redis-tools-5-7.0.15-1ubuntu0.24.04.4.txt → os-redis-tools-5-7.0.15-1ubuntu0.24.04.5.txt} +0 -0
- pipeline/bake.py +19 -8
- pipeline/config.py +17 -0
- pipeline/package_download/__init__.py +19 -0
- pipeline/package_download/__main__.py +35 -0
- pipeline/package_download/app.py +417 -0
- pipeline/package_download/bundle.py +468 -0
- pipeline/package_download/catalog.py +131 -0
- pipeline/package_download/settings.py +102 -0
- pipeline/package_download/source.py +115 -0
- pipeline/package_download/telemetry.py +68 -0
Dockerfile
CHANGED
|
@@ -284,15 +284,14 @@ COPY docker/hf-space/supervisord-gpu.conf /etc/supervisor/conf.d/hf-space.conf
|
|
| 284 |
|
| 285 |
COPY docker/hf-space/nginx.conf.template /etc/nginx/hf-space.conf.template
|
| 286 |
|
| 287 |
-
# SimReady Central's validator (supervisord's [program:deeptag-validate], nginx's
|
| 288 |
-
# /vlm_validate/search_result): one vendored file, run by the app venv, which already
|
| 289 |
-
# has its fastapi, uvicorn, httpx, pydantic and pillow (deepsearch-api's own).
|
| 290 |
-
COPY docker/hf-space/deeptag-validate/app.py /app/deeptag-validate/app.py
|
| 291 |
-
|
| 292 |
COPY services/explorer/dist /usr/share/nginx/explorer
|
| 293 |
|
| 294 |
|
| 295 |
COPY --chown=1000:1000 pipeline /app/hf-space-pipeline/pipeline
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
ARG ARTIFACT_S3_URI
|
| 298 |
ARG ARTIFACT_S3_REGION=us-east-2
|
|
@@ -314,7 +313,8 @@ ARG ES_NAME
|
|
| 314 |
ARG S3_STORAGE_BUCKET_NAME
|
| 315 |
ARG S3_STORAGE_REGION_NAME=us-west-2
|
| 316 |
ARG STORAGE_BACKEND_TYPE=s3
|
| 317 |
-
# DOWNLOAD_ENABLED / HF_STORAGE_API_TOKEN / SUBMIT_CONTENT_URL / STORAGE_API_* / DOWNLOAD_*
|
|
|
|
| 318 |
# docker/Dockerfile.hf-space's own copies of these comments for the full
|
| 319 |
# explanation.
|
| 320 |
# USDSEARCH_URL_MAPPING_MODE/_RULES — hf_space_build_args() derives them from
|
|
@@ -342,6 +342,10 @@ ENV HF_STORAGE_API_TOKEN= \
|
|
| 342 |
ASSERT_ADMIN_USER=false \
|
| 343 |
USDSEARCH_VISION_VALIDATION_ENABLED=false \
|
| 344 |
USDSEARCH_LLM_PARSING_ENABLED=false \
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
USDSEARCH_LLM_PARSING_FIELDS_FILEPATH=/app/search_fields.yaml \
|
| 346 |
# Business telemetry — same as docker/Dockerfile.hf-space, see its comment.
|
| 347 |
OTEL_SDK_DISABLED=false \
|
|
@@ -362,6 +366,10 @@ ENV HF_STORAGE_API_TOKEN= \
|
|
| 362 |
DOWNLOAD_MAX_CONCURRENT=2 \
|
| 363 |
DOWNLOAD_QUEUE_TIMEOUT=120 \
|
| 364 |
DOWNLOAD_MAX_BUNDLE_BYTES=2000000000 \
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
STORAGE_API_THUMBNAIL_METADATA_FIELDS="[\"hf:thumbnail_url\"]" \
|
| 366 |
SIGLIP2_TRITON_SERVER_URL=localhost:8001 \
|
| 367 |
TRITON_SERVER_URL=localhost:8001 \
|
|
|
|
| 284 |
|
| 285 |
COPY docker/hf-space/nginx.conf.template /etc/nginx/hf-space.conf.template
|
| 286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
COPY services/explorer/dist /usr/share/nginx/explorer
|
| 288 |
|
| 289 |
|
| 290 |
COPY --chown=1000:1000 pipeline /app/hf-space-pipeline/pipeline
|
| 291 |
+
# package-download (supervisord's program of that name) runs from this copy on the
|
| 292 |
+
# venv deepsearch-api already brought (fastapi, uvicorn, grpcio, the storage_api
|
| 293 |
+
# stubs, search_utils): fail the build, not the Space, if that ever stops importing.
|
| 294 |
+
RUN cd /app/hf-space-pipeline && env -u PYTHONPATH /app/.venv/bin/python -c "import pipeline.package_download.app, pipeline.package_download.source, search_utils.url_mapping"
|
| 295 |
|
| 296 |
ARG ARTIFACT_S3_URI
|
| 297 |
ARG ARTIFACT_S3_REGION=us-east-2
|
|
|
|
| 313 |
ARG S3_STORAGE_BUCKET_NAME
|
| 314 |
ARG S3_STORAGE_REGION_NAME=us-west-2
|
| 315 |
ARG STORAGE_BACKEND_TYPE=s3
|
| 316 |
+
# DOWNLOAD_ENABLED / HF_STORAGE_API_TOKEN / SUBMIT_CONTENT_URL / STORAGE_API_* / DOWNLOAD_* /
|
| 317 |
+
# PACKAGE_DOWNLOAD_* — see
|
| 318 |
# docker/Dockerfile.hf-space's own copies of these comments for the full
|
| 319 |
# explanation.
|
| 320 |
# USDSEARCH_URL_MAPPING_MODE/_RULES — hf_space_build_args() derives them from
|
|
|
|
| 342 |
ASSERT_ADMIN_USER=false \
|
| 343 |
USDSEARCH_VISION_VALIDATION_ENABLED=false \
|
| 344 |
USDSEARCH_LLM_PARSING_ENABLED=false \
|
| 345 |
+
# Validation limits are per deepsearch-api worker: 8 checks in flight and a 125k-verdict cache in
|
| 346 |
+
# each of the 4 workers (DEEPSEARCH_API_WORKERS below), 32 and 500k in all as on the cpu image.
|
| 347 |
+
USDSEARCH_VISION_VALIDATION_MAX_CONCURRENT=8 \
|
| 348 |
+
USDSEARCH_VISION_VALIDATION_CACHE_SIZE=125000 \
|
| 349 |
USDSEARCH_LLM_PARSING_FIELDS_FILEPATH=/app/search_fields.yaml \
|
| 350 |
# Business telemetry — same as docker/Dockerfile.hf-space, see its comment.
|
| 351 |
OTEL_SDK_DISABLED=false \
|
|
|
|
| 366 |
DOWNLOAD_MAX_CONCURRENT=2 \
|
| 367 |
DOWNLOAD_QUEUE_TIMEOUT=120 \
|
| 368 |
DOWNLOAD_MAX_BUNDLE_BYTES=2000000000 \
|
| 369 |
+
PACKAGE_DOWNLOAD_PORT=8020 \
|
| 370 |
+
PACKAGE_DOWNLOAD_MAX_CONCURRENT=4 \
|
| 371 |
+
PACKAGE_DOWNLOAD_QUEUE_TIMEOUT=120 \
|
| 372 |
+
PACKAGE_DOWNLOAD_MAX_BUNDLE_BYTES=2000000000 \
|
| 373 |
STORAGE_API_THUMBNAIL_METADATA_FIELDS="[\"hf:thumbnail_url\"]" \
|
| 374 |
SIGLIP2_TRITON_SERVER_URL=localhost:8001 \
|
| 375 |
TRITON_SERVER_URL=localhost:8001 \
|
THIRD_PARTY_NOTICE.md
CHANGED
|
@@ -31,7 +31,7 @@ the Hugging Face datasets the assets are downloaded from).
|
|
| 31 |
Generated by `scripts/generate_third_party_notice.sh` in the internal
|
| 32 |
source repository (simready-central-indexing-internal); do not hand-edit.
|
| 33 |
|
| 34 |
-
**
|
| 35 |
|
| 36 |
| Package | Version | License | Homepage | Sources | License text |
|
| 37 |
| --- | --- | --- | --- | --- | --- |
|
|
@@ -71,8 +71,7 @@ source repository (simready-central-indexing-internal); do not hand-edit.
|
|
| 71 |
| `filelock` | 3.29.0 | MIT | <https://github.com/tox-dev/py-filelock> | app-venv | [`licenses/filelock-3.29.0.txt`](licenses/filelock-3.29.0.txt) |
|
| 72 |
| `fire` | 0.7.1 | Apache-2.0 | <https://github.com/google/python-fire> | app-venv | [`licenses/fire-0.7.1.txt`](licenses/fire-0.7.1.txt) |
|
| 73 |
| `frozenlist` | 1.8.0 | Apache-2.0 | <https://github.com/aio-libs/frozenlist> | app-venv | [`licenses/frozenlist-1.8.0.txt`](licenses/frozenlist-1.8.0.txt) |
|
| 74 |
-
| `fsspec` | 2026.
|
| 75 |
-
| `fsspec` | 2026.4.0 | BSD-3-Clause | <https://github.com/fsspec/filesystem_spec> | siglip2-triton | [`licenses/fsspec-2026.4.0.txt`](licenses/fsspec-2026.4.0.txt) |
|
| 76 |
| `googleapis-common-protos` | 1.74.0 | Apache 2.0 | <https://github.com/googleapis/google-cloud-python/tree/main/packages/googleapis-common-protos> | app-venv | [`licenses/googleapis-common-protos-1.74.0.txt`](licenses/googleapis-common-protos-1.74.0.txt) |
|
| 77 |
| `greenlet` | 3.4.0 | MIT AND PSF-2.0 | <https://greenlet.readthedocs.io> | app-venv | [`licenses/greenlet-3.4.0.txt`](licenses/greenlet-3.4.0.txt) |
|
| 78 |
| `grpcio` | 1.80.0 | Apache-2.0 | <https://grpc.io> | app-venv | [`licenses/grpcio-1.80.0.txt`](licenses/grpcio-1.80.0.txt) |
|
|
@@ -103,7 +102,7 @@ source repository (simready-central-indexing-internal); do not hand-edit.
|
|
| 103 |
| `langgraph` | 1.2.11 | MIT | <https://docs.langchain.com/oss/python/langgraph/overview> | app-venv | [`licenses/langgraph-1.2.11.txt`](licenses/langgraph-1.2.11.txt) |
|
| 104 |
| `langgraph-checkpoint` | 4.1.1 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint> | app-venv | [`licenses/langgraph-checkpoint-4.1.1.txt`](licenses/langgraph-checkpoint-4.1.1.txt) |
|
| 105 |
| `langgraph-prebuilt` | 1.1.0 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/prebuilt> | app-venv | [`licenses/langgraph-prebuilt-1.1.0.txt`](licenses/langgraph-prebuilt-1.1.0.txt) |
|
| 106 |
-
| `langgraph-sdk` | 0.4.
|
| 107 |
| `langsmith` | 0.8.18 | MIT | <https://smith.langchain.com/> | app-venv | [`licenses/langsmith-0.8.18.txt`](licenses/langsmith-0.8.18.txt) |
|
| 108 |
| `markdown-it-py` | 4.0.0 | MIT License | <https://github.com/executablebooks/markdown-it-py> | app-venv | [`licenses/markdown-it-py-4.0.0.txt`](licenses/markdown-it-py-4.0.0.txt) |
|
| 109 |
| `markdown-it-py` | 4.2.0 | MIT License | <https://github.com/executablebooks/markdown-it-py> | siglip2-triton | [`licenses/markdown-it-py-4.2.0.txt`](licenses/markdown-it-py-4.2.0.txt) |
|
|
@@ -233,6 +232,6 @@ inferred from each package's `/usr/share/doc/<pkg>/copyright`.
|
|
| 233 |
| `python3.12-venv` | 3.12.3-1ubuntu0.17 | Python-2.0 | <https://packages.ubuntu.com/search?keywords=python3.12-venv> | hf-space-gpu | [`licenses/os-python3.12-venv-3.12.3-1ubuntu0.17.txt`](licenses/os-python3.12-venv-3.12.3-1ubuntu0.17.txt) |
|
| 234 |
| `python3-pip-whl` | 24.0+dfsg-1ubuntu1.3 | Expat, Apache-2.0, MPL-2, LGPL-2.1+, BSD-3, Python, Apache-2.0 OR BSD-2, ISC, BSD-2 | <https://packages.ubuntu.com/search?keywords=python3-pip-whl> | hf-space-gpu | [`licenses/os-python3-pip-whl-24.0-dfsg-1ubuntu1.3.txt`](licenses/os-python3-pip-whl-24.0-dfsg-1ubuntu1.3.txt) |
|
| 235 |
| `python3-setuptools-whl` | 68.1.2-2ubuntu1.2 | Apache-2.0 | <https://packages.ubuntu.com/search?keywords=python3-setuptools-whl> | hf-space-gpu | [`licenses/os-python3-setuptools-whl-68.1.2-2ubuntu1.2.txt`](licenses/os-python3-setuptools-whl-68.1.2-2ubuntu1.2.txt) |
|
| 236 |
-
| `redis-server` | 5:7.0.15-1ubuntu0.24.04.
|
| 237 |
-
| `redis-tools` | 5:7.0.15-1ubuntu0.24.04.
|
| 238 |
| `supervisor` | 4.2.5-1ubuntu0.1 | BSD-derived, RDFLIB, GPL-2+, Medusa, BSD-Derived | <https://packages.ubuntu.com/search?keywords=supervisor> | hf-space-gpu | [`licenses/os-supervisor-4.2.5-1ubuntu0.1.txt`](licenses/os-supervisor-4.2.5-1ubuntu0.1.txt) |
|
|
|
|
| 31 |
Generated by `scripts/generate_third_party_notice.sh` in the internal
|
| 32 |
source repository (simready-central-indexing-internal); do not hand-edit.
|
| 33 |
|
| 34 |
+
**152** unique `(package, version)` entries.
|
| 35 |
|
| 36 |
| Package | Version | License | Homepage | Sources | License text |
|
| 37 |
| --- | --- | --- | --- | --- | --- |
|
|
|
|
| 71 |
| `filelock` | 3.29.0 | MIT | <https://github.com/tox-dev/py-filelock> | app-venv | [`licenses/filelock-3.29.0.txt`](licenses/filelock-3.29.0.txt) |
|
| 72 |
| `fire` | 0.7.1 | Apache-2.0 | <https://github.com/google/python-fire> | app-venv | [`licenses/fire-0.7.1.txt`](licenses/fire-0.7.1.txt) |
|
| 73 |
| `frozenlist` | 1.8.0 | Apache-2.0 | <https://github.com/aio-libs/frozenlist> | app-venv | [`licenses/frozenlist-1.8.0.txt`](licenses/frozenlist-1.8.0.txt) |
|
| 74 |
+
| `fsspec` | 2026.9.0 | BSD-3-Clause | <https://github.com/fsspec/filesystem_spec> | app-venv, siglip2-triton | [`licenses/fsspec-2026.9.0.txt`](licenses/fsspec-2026.9.0.txt) |
|
|
|
|
| 75 |
| `googleapis-common-protos` | 1.74.0 | Apache 2.0 | <https://github.com/googleapis/google-cloud-python/tree/main/packages/googleapis-common-protos> | app-venv | [`licenses/googleapis-common-protos-1.74.0.txt`](licenses/googleapis-common-protos-1.74.0.txt) |
|
| 76 |
| `greenlet` | 3.4.0 | MIT AND PSF-2.0 | <https://greenlet.readthedocs.io> | app-venv | [`licenses/greenlet-3.4.0.txt`](licenses/greenlet-3.4.0.txt) |
|
| 77 |
| `grpcio` | 1.80.0 | Apache-2.0 | <https://grpc.io> | app-venv | [`licenses/grpcio-1.80.0.txt`](licenses/grpcio-1.80.0.txt) |
|
|
|
|
| 102 |
| `langgraph` | 1.2.11 | MIT | <https://docs.langchain.com/oss/python/langgraph/overview> | app-venv | [`licenses/langgraph-1.2.11.txt`](licenses/langgraph-1.2.11.txt) |
|
| 103 |
| `langgraph-checkpoint` | 4.1.1 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint> | app-venv | [`licenses/langgraph-checkpoint-4.1.1.txt`](licenses/langgraph-checkpoint-4.1.1.txt) |
|
| 104 |
| `langgraph-prebuilt` | 1.1.0 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/prebuilt> | app-venv | [`licenses/langgraph-prebuilt-1.1.0.txt`](licenses/langgraph-prebuilt-1.1.0.txt) |
|
| 105 |
+
| `langgraph-sdk` | 0.4.6 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/sdk-py> | app-venv | [`licenses/langgraph-sdk-0.4.6.txt`](licenses/langgraph-sdk-0.4.6.txt) |
|
| 106 |
| `langsmith` | 0.8.18 | MIT | <https://smith.langchain.com/> | app-venv | [`licenses/langsmith-0.8.18.txt`](licenses/langsmith-0.8.18.txt) |
|
| 107 |
| `markdown-it-py` | 4.0.0 | MIT License | <https://github.com/executablebooks/markdown-it-py> | app-venv | [`licenses/markdown-it-py-4.0.0.txt`](licenses/markdown-it-py-4.0.0.txt) |
|
| 108 |
| `markdown-it-py` | 4.2.0 | MIT License | <https://github.com/executablebooks/markdown-it-py> | siglip2-triton | [`licenses/markdown-it-py-4.2.0.txt`](licenses/markdown-it-py-4.2.0.txt) |
|
|
|
|
| 232 |
| `python3.12-venv` | 3.12.3-1ubuntu0.17 | Python-2.0 | <https://packages.ubuntu.com/search?keywords=python3.12-venv> | hf-space-gpu | [`licenses/os-python3.12-venv-3.12.3-1ubuntu0.17.txt`](licenses/os-python3.12-venv-3.12.3-1ubuntu0.17.txt) |
|
| 233 |
| `python3-pip-whl` | 24.0+dfsg-1ubuntu1.3 | Expat, Apache-2.0, MPL-2, LGPL-2.1+, BSD-3, Python, Apache-2.0 OR BSD-2, ISC, BSD-2 | <https://packages.ubuntu.com/search?keywords=python3-pip-whl> | hf-space-gpu | [`licenses/os-python3-pip-whl-24.0-dfsg-1ubuntu1.3.txt`](licenses/os-python3-pip-whl-24.0-dfsg-1ubuntu1.3.txt) |
|
| 234 |
| `python3-setuptools-whl` | 68.1.2-2ubuntu1.2 | Apache-2.0 | <https://packages.ubuntu.com/search?keywords=python3-setuptools-whl> | hf-space-gpu | [`licenses/os-python3-setuptools-whl-68.1.2-2ubuntu1.2.txt`](licenses/os-python3-setuptools-whl-68.1.2-2ubuntu1.2.txt) |
|
| 235 |
+
| `redis-server` | 5:7.0.15-1ubuntu0.24.04.5 | BSD-3-clause, BSD-2-clause, MIT | <https://packages.ubuntu.com/search?keywords=redis-server> | hf-space-gpu | [`licenses/os-redis-server-5-7.0.15-1ubuntu0.24.04.5.txt`](licenses/os-redis-server-5-7.0.15-1ubuntu0.24.04.5.txt) |
|
| 236 |
+
| `redis-tools` | 5:7.0.15-1ubuntu0.24.04.5 | BSD-3-clause, BSD-2-clause, MIT | <https://packages.ubuntu.com/search?keywords=redis-tools> | hf-space-gpu | [`licenses/os-redis-tools-5-7.0.15-1ubuntu0.24.04.5.txt`](licenses/os-redis-tools-5-7.0.15-1ubuntu0.24.04.5.txt) |
|
| 237 |
| `supervisor` | 4.2.5-1ubuntu0.1 | BSD-derived, RDFLIB, GPL-2+, Medusa, BSD-Derived | <https://packages.ubuntu.com/search?keywords=supervisor> | hf-space-gpu | [`licenses/os-supervisor-4.2.5-1ubuntu0.1.txt`](licenses/os-supervisor-4.2.5-1ubuntu0.1.txt) |
|
docker/hf-space/deeptag-validate/app.py
DELETED
|
@@ -1,836 +0,0 @@
|
|
| 1 |
-
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
-
"""
|
| 4 |
-
deeptag-validate — VLM relevance-validation shim.
|
| 5 |
-
|
| 6 |
-
Two routes, one scorer:
|
| 7 |
-
|
| 8 |
-
- `POST /vlm_validate/search_result` — the original per-image contract: the
|
| 9 |
-
browser uploads one base64 thumbnail per call (~100 requests per search).
|
| 10 |
-
- `POST /vlm_validate/batch_stream` — the streamed batch contract (task #27):
|
| 11 |
-
ONE request per result page carrying only ids + image references; the shim
|
| 12 |
-
fetches each thumbnail ITSELF through the gateway's `/images` (LAN hop,
|
| 13 |
-
shared 7-day nginx cache the browsers already warmed, index-scoped by the
|
| 14 |
-
request's `index` slug) and streams verdicts back as NDJSON lines the
|
| 15 |
-
moment each lands — shim-cache hits first, then misses dispatched in
|
| 16 |
-
REQUEST (retrieval) order. Client disconnect cancels every queued engine
|
| 17 |
-
call (the whole point of the per-request task set — an abandoned query
|
| 18 |
-
must not strand work on the shared engine).
|
| 19 |
-
|
| 20 |
-
Bridges the Explorer's `/vlm_validate/*` contract to the self-hosted
|
| 21 |
-
unified vlm-parser-reranker engine (Qwen3.6-35B-A3B-FP8 on one RTX PRO 6000, served alias
|
| 22 |
-
"reranker" — the same
|
| 23 |
-
engine also serves query parsing under the alias "parser"). The engine is used
|
| 24 |
-
as a relevance scorer via the first-token logprob trick: it reads the verdict
|
| 25 |
-
distribution from one forward pass (enable_thinking=false; Qwen thinks by
|
| 26 |
-
default, so a missed flag silently corrupts verdicts), ~20x faster than
|
| 27 |
-
generating a verdict. We return the SAME shape and scale as usdsearch's own
|
| 28 |
-
validator (llm_client's log-prob method), minus the `reasoning` the UI no
|
| 29 |
-
longer shows:
|
| 30 |
-
|
| 31 |
-
{"is_match": bool, "confidence": float, "similarity_score": float, "model": str}
|
| 32 |
-
|
| 33 |
-
ONE verdict method on every route, the Qwen Yes/No log-prob verdict: a Yes/No
|
| 34 |
-
prompt (JUDGE_PROMPT for text queries, JUDGE_IMAGE_PROMPT for an image alone,
|
| 35 |
-
JUDGE_IMAGE_TEXT_PROMPT for an image with words), P(Yes) read from the
|
| 36 |
-
first-token log-probs (position scan, Yes/No mass renormalized), is_match =
|
| 37 |
-
P(Yes) > 0.5, confidence = P(Yes) in [0,1] and similarity_score = P(Yes) x 100
|
| 38 |
-
in [0,100] — usdsearch's scale, so the Explorer reads every validator's score
|
| 39 |
-
the same way (2026-10-06: the shim sent P(Yes) in [0,1] until then, and the
|
| 40 |
-
Explorer's per-value guess showed a usdsearch reject at P(Yes) 0.0076 as 76 %).
|
| 41 |
-
With JUDGE_ASSET_CONTEXT on (2026-10-07), a words-only check also reads the
|
| 42 |
-
candidate's file path and the index's caption, between the query and the
|
| 43 |
-
question (_select_prompt); image checks never do.
|
| 44 |
-
is_match coincides with the 50 % badge boundary the Explorer hardcodes (a
|
| 45 |
-
false is_match renders the orange "VLM rejected, likely false positive" chip,
|
| 46 |
-
and searchOutcome.js's WEAK state means "no hit at or above 50%"). This is the
|
| 47 |
-
verdict the 2026-09-14/15 evaluations measured
|
| 48 |
-
(docs/decisions/2026-09-search-quality.md). The graded 0/1/2 method and the
|
| 49 |
-
Gemma-4 engine it targeted were evaluated on 2026-09-15 and rejected (user
|
| 50 |
-
decision).
|
| 51 |
-
|
| 52 |
-
Validator-contract semantics (the authoritative engine + client contracts
|
| 53 |
-
live in INTEGRATION.md of https://gitlab-master.nvidia.com/omniverse/deeptag/parser-reranker):
|
| 54 |
-
- ABSTAIN (Yes/No probability mass below threshold at every position) → retry
|
| 55 |
-
the call once; a persistent abstain is returned as is_match=true with
|
| 56 |
-
abstain=true and confidence 0.0 — the candidate KEEPS its retrieval-order
|
| 57 |
-
place, unscored. An abstain must never be treated as "No": rejecting on an
|
| 58 |
-
unreliable read silently deletes good results.
|
| 59 |
-
- Transient failure (connect error / engine 5xx) → back off and retry once,
|
| 60 |
-
then fail-soft: 503/502, which the Explorer reads as "VLM unavailable": it
|
| 61 |
-
shows the VLM-unavailable banner and stops validating (verdicts already
|
| 62 |
-
collected keep their badges), leaving search intact. Scores are never
|
| 63 |
-
fabricated.
|
| 64 |
-
|
| 65 |
-
Throughput: the serving owner's operational contract caps validation fan-out
|
| 66 |
-
at 24 in-flight requests (engine-side 42.9 verdicts/s at that fan-out,
|
| 67 |
-
measured on the earlier L40S engine; end-to-end the shim sustains less because it also fetches the
|
| 68 |
-
thumbnails; more in-flight degrades p95 and adds nothing — the engine also
|
| 69 |
-
serves query parsing), so this shim is tuned
|
| 70 |
-
client-side:
|
| 71 |
-
- ONE pooled keep-alive HTTP client (lifespan-managed) — no per-call TCP
|
| 72 |
-
handshake tax (~an RTT each on ~100 calls per search);
|
| 73 |
-
- images reach the engine at most VALIDATE_MAX_EDGE (448) px on the longest
|
| 74 |
-
edge (2026-10-07): Isaac 6.2 stores every SimReady thumbnail at 1024x1024,
|
| 75 |
-
1,078 prompt tokens per verdict at that size against 250 at 448 (robots stay
|
| 76 |
-
256/512 px; anything at or under the cap passes through untouched). A JPEG
|
| 77 |
-
decodes at a reduced DCT scale first (draft), so the resize costs ~3 ms of
|
| 78 |
-
CPU per 1024-px image. Measured in docs/decisions/2026-09-search-quality.md:
|
| 79 |
-
65 vs 38 verdicts/s at saturation, 97 % of Yes/No verdicts unchanged;
|
| 80 |
-
- an asyncio.Semaphore caps in-flight reranker calls at the contract's
|
| 81 |
-
fan-out ceiling, converting bursts into queueing at the sweet spot;
|
| 82 |
-
- max_tokens 2: Qwen3.6 emits the verdict at position 0 (no wrapper tokens —
|
| 83 |
-
verified by probe); the position SCAN below is kept anyway, per the
|
| 84 |
-
integration contract, so a future wrapper-emitting model still extracts.
|
| 85 |
-
|
| 86 |
-
Environment:
|
| 87 |
-
RERANKER_BASE_URL OpenAI-compatible engine base URL
|
| 88 |
-
(default http://simready-central-prod-cluster.nvidia.com/vlm-parser-reranker/v1)
|
| 89 |
-
RERANKER_MODEL served model name (default "reranker")
|
| 90 |
-
RERANKER_API_KEY bearer key for an engine that wants one (default
|
| 91 |
-
none: the cluster engine is open). Sent on the
|
| 92 |
-
engine's chat calls only, never on an image fetch.
|
| 93 |
-
RERANKER_NAME display name in responses (default "Qwen3.6-35B-A3B-FP8";
|
| 94 |
-
the `model` field in every verdict)
|
| 95 |
-
RERANKER_COVER_MIN Yes/No verdict-token mass below this → abstain
|
| 96 |
-
(default 0.2).
|
| 97 |
-
RERANKER_TIMEOUT_S per-call timeout (default 30, per the serving
|
| 98 |
-
contract; a 50-hit page drained in ≈1.2 s, 1.46 s p95, on the earlier L40S)
|
| 99 |
-
RERANKER_MAX_TOKENS decode steps per call (default 2; raise if a
|
| 100 |
-
future model buries the verdict deeper)
|
| 101 |
-
RERANKER_MAX_CONCURRENCY in-flight cap toward the engine (default 48 for
|
| 102 |
-
the RTX PRO 6000 engine with async scheduling and
|
| 103 |
-
two API servers: 3 clients x c48 = 120.9 verdicts/s
|
| 104 |
-
at p95 1.41 s; 24 was the L40-class ceiling)
|
| 105 |
-
VALIDATE_MAX_EDGE downscale images whose longest edge exceeds this
|
| 106 |
-
many pixels before sending (default 448 since
|
| 107 |
-
2026-10-07; 4096 = no resize from 2026-09-21,
|
| 108 |
-
512 before 2026-09-15). The verdict cache key
|
| 109 |
-
includes it: changing it starts the shim's cache
|
| 110 |
-
cold.
|
| 111 |
-
RERANKER_BATCH_CONCURRENCY per-batch fan-out cap INSIDE the global
|
| 112 |
-
semaphore (default 24) — two users' concurrent
|
| 113 |
-
batches interleave at the engine instead of one
|
| 114 |
-
queueing whole behind the other.
|
| 115 |
-
SHIM_GATEWAY_BASE_URL the gateway's public listener, for shim-side
|
| 116 |
-
thumbnail fetches (default http://gateway:8080).
|
| 117 |
-
IMAGES_TIMEOUT_S per-thumbnail fetch timeout on the dedicated
|
| 118 |
-
image client (default 5; slow thumbnails must
|
| 119 |
-
not starve the engine connection pool).
|
| 120 |
-
BATCH_MAX_ITEMS batch size ceiling (default 200; larger → 413).
|
| 121 |
-
BATCH_HEARTBEAT_S blank-line heartbeat cadence on an otherwise
|
| 122 |
-
quiet stream. COUPLED CONSTANT — the gateway's
|
| 123 |
-
batch_stream proxy_read_timeout (60 s, hard-
|
| 124 |
-
coded) and the Explorer's stream stall guard
|
| 125 |
-
(45 s) are both sized off the default 10:
|
| 126 |
-
raising it past ~40 trips the client guard,
|
| 127 |
-
past 60 the gateway severs healthy streams.
|
| 128 |
-
VALIDATE_CACHE_SIZE verdict-cache entries (default 4000000, ~0.9 GB
|
| 129 |
-
at 240 bytes an entry; two entries per verdict;
|
| 130 |
-
0 disables; 20000 until 2026-10-07).
|
| 131 |
-
Verdicts are deterministic (temperature 0), so one
|
| 132 |
-
(model, max-edge, query, image-bytes) tuple always
|
| 133 |
-
scores the same — caching makes repeat waves
|
| 134 |
-
(re-searches, pagination bounces, other USERS
|
| 135 |
-
asking the same query) free. Complements the
|
| 136 |
-
Explorer's per-browser verdict cache: this one is
|
| 137 |
-
shared across every client of this shim. Keyed on
|
| 138 |
-
the prompt too (a different query never
|
| 139 |
-
coalesces).
|
| 140 |
-
|
| 141 |
-
Tests (tests/test_scoring.py: the scorer, the payload builder, the verdict
|
| 142 |
-
contract and the cache keys as pure functions; tests/test_routes.py: both
|
| 143 |
-
routes through fastapi.testclient with the engine call and the image fetch
|
| 144 |
-
monkeypatched, i.e. the prompt selection, the two cache paths, the NDJSON line
|
| 145 |
-
protocol and the per-item vs fatal error split; no engine, no network), from
|
| 146 |
-
the repo root:
|
| 147 |
-
|
| 148 |
-
cd deeptag-validate && pip install -r requirements-dev.txt && python -m pytest -q
|
| 149 |
-
# or, with no install step:
|
| 150 |
-
uv run --with pytest --with fastapi --with httpx --with pillow pytest deeptag-validate/tests
|
| 151 |
-
|
| 152 |
-
CI runs the first form as the `test-deeptag-validate` job, which gates
|
| 153 |
-
`build-deeptag-validate` exactly like the simready-api suite gates its image.
|
| 154 |
-
"""
|
| 155 |
-
import asyncio
|
| 156 |
-
import base64
|
| 157 |
-
import hashlib
|
| 158 |
-
import io
|
| 159 |
-
import json
|
| 160 |
-
import logging
|
| 161 |
-
import math
|
| 162 |
-
import os
|
| 163 |
-
import re
|
| 164 |
-
from collections import OrderedDict
|
| 165 |
-
from contextlib import asynccontextmanager
|
| 166 |
-
from typing import List, Optional
|
| 167 |
-
|
| 168 |
-
import httpx
|
| 169 |
-
from fastapi import FastAPI, HTTPException
|
| 170 |
-
from fastapi.responses import StreamingResponse
|
| 171 |
-
from PIL import Image
|
| 172 |
-
from pydantic import BaseModel
|
| 173 |
-
|
| 174 |
-
logger = logging.getLogger("deeptag-validate")
|
| 175 |
-
|
| 176 |
-
# The unified vlm-parser-reranker engine's "reranker" alias, OpenAI-compatible. Defaults to
|
| 177 |
-
# the cluster engine's ingress; override per deployment. It does not gate on an API key.
|
| 178 |
-
RERANKER_BASE_URL = os.environ.get("RERANKER_BASE_URL", "http://simready-central-prod-cluster.nvidia.com/vlm-parser-reranker/v1").rstrip("/")
|
| 179 |
-
RERANKER_MODEL = os.environ.get("RERANKER_MODEL", "reranker")
|
| 180 |
-
# A hosted engine (the HF Spaces' NVCF function / Inference Endpoint) wants a bearer key. It rides the
|
| 181 |
-
# engine's POSTs alone: the same pooled client also fetches http image URLs a request names, and a
|
| 182 |
-
# default header there would hand the key to any origin.
|
| 183 |
-
RERANKER_API_KEY = os.environ.get("RERANKER_API_KEY", "").strip()
|
| 184 |
-
_ENGINE_HEADERS = {"Authorization": f"Bearer {RERANKER_API_KEY}"} if RERANKER_API_KEY else None
|
| 185 |
-
RERANKER_NAME = os.environ.get("RERANKER_NAME", "Qwen3.6-35B-A3B-FP8")
|
| 186 |
-
|
| 187 |
-
# Yes/No verdict-token probability mass below this is unreliable → abstain
|
| 188 |
-
# (treat as unscored, never as "No").
|
| 189 |
-
COVER_MIN = float(os.environ.get("RERANKER_COVER_MIN", "0.2"))
|
| 190 |
-
TIMEOUT_S = float(os.environ.get("RERANKER_TIMEOUT_S", "30"))
|
| 191 |
-
# Qwen3.6 emits the verdict at position 0; one spare decode step is plenty and
|
| 192 |
-
# more only burns shared engine compute (it also serves query parsing).
|
| 193 |
-
RERANKER_MAX_TOKENS = int(os.environ.get("RERANKER_MAX_TOKENS", "2"))
|
| 194 |
-
# The serving contract's fan-out ceiling: past 24 in-flight, p95 degrades and
|
| 195 |
-
# throughput gains nothing.
|
| 196 |
-
RERANKER_MAX_CONCURRENCY = int(os.environ.get("RERANKER_MAX_CONCURRENCY", "48"))
|
| 197 |
-
# Vision-token cost scales with pixels; 448 px (longest edge) is the one view
|
| 198 |
-
# the production Yes/No verdict was measured at — the same edge the server-side
|
| 199 |
-
# verdict stage judges (docs/operations.md "Measured quality and the ordering
|
| 200 |
-
# rule"). The cache key carries the edge, so changing it never re-serves a
|
| 201 |
-
# verdict judged at another size.
|
| 202 |
-
VALIDATE_MAX_EDGE = int(os.environ.get("VALIDATE_MAX_EDGE", "448"))
|
| 203 |
-
|
| 204 |
-
# The asset's own facts in the words-only judge prompt (2026-10-07): "off" (the
|
| 205 |
-
# 2026-09 prompt), "path" (the candidate's file path) or "path+caption" (+ the
|
| 206 |
-
# index's one-line description). The client sends each hit's `path` and
|
| 207 |
-
# `caption`; a hit without a path is judged without facts, and image queries
|
| 208 |
-
# never use them (only words-only judging was evaluated). Measured in
|
| 209 |
-
# docs/decisions/2026-09-search-quality.md.
|
| 210 |
-
ASSET_CONTEXT_MODES = ("off", "path", "path+caption")
|
| 211 |
-
JUDGE_ASSET_CONTEXT = os.environ.get("JUDGE_ASSET_CONTEXT", "off").strip().lower()
|
| 212 |
-
if JUDGE_ASSET_CONTEXT not in ASSET_CONTEXT_MODES:
|
| 213 |
-
logger.warning(
|
| 214 |
-
"JUDGE_ASSET_CONTEXT=%r is not one of %s: judging without asset facts",
|
| 215 |
-
JUDGE_ASSET_CONTEXT, ", ".join(ASSET_CONTEXT_MODES),
|
| 216 |
-
)
|
| 217 |
-
JUDGE_ASSET_CONTEXT = "off"
|
| 218 |
-
# Prompt-length bounds for the facts: a path keeps its tail (the file and its
|
| 219 |
-
# folders name the asset), a caption its head.
|
| 220 |
-
ASSET_PATH_MAX = 300
|
| 221 |
-
ASSET_CAPTION_MAX = 200
|
| 222 |
-
|
| 223 |
-
# Streamed batch validation (task #27). The per-batch cap nests INSIDE the
|
| 224 |
-
# global engine semaphore: a 50-item batch occupies at most this many of the
|
| 225 |
-
# 24 engine slots at once, so concurrent users' batches interleave fairly.
|
| 226 |
-
RERANKER_BATCH_CONCURRENCY = int(os.environ.get("RERANKER_BATCH_CONCURRENCY", "24"))
|
| 227 |
-
# Shim-side thumbnail fetches go through the gateway's public listener — the
|
| 228 |
-
# same /images route (and shared nginx cache) the browsers use, index-scoped
|
| 229 |
-
# by the x-usdsearch-storage-backend header. NEVER a client-supplied URL: the
|
| 230 |
-
# base is pinned here and references travel as urlencoded query params only.
|
| 231 |
-
SHIM_GATEWAY_BASE_URL = os.environ.get("SHIM_GATEWAY_BASE_URL", "http://gateway:8080").rstrip("/")
|
| 232 |
-
IMAGES_TIMEOUT_S = float(os.environ.get("IMAGES_TIMEOUT_S", "5"))
|
| 233 |
-
BATCH_MAX_ITEMS = int(os.environ.get("BATCH_MAX_ITEMS", "200"))
|
| 234 |
-
BATCH_HEARTBEAT_S = float(os.environ.get("BATCH_HEARTBEAT_S", "10"))
|
| 235 |
-
|
| 236 |
-
# Cross-user verdict cache: temperature-0 verdicts are pure functions of
|
| 237 |
-
# (model, resize edge, query, image bytes), so identical pairs — from ANY
|
| 238 |
-
# client — re-serve without an engine call. Insertion-order eviction. An entry
|
| 239 |
-
# costs ~240 bytes (measured on python:3.11), and each fresh verdict writes two
|
| 240 |
-
# (its reference key and its content key), so 4,000,000 entries hold 2,000,000
|
| 241 |
-
# verdicts in ~0.9 GB: about half a day at the capacity plan's Phase 1 load
|
| 242 |
-
# (2026-10-07; 20000 before was ~30 s of traffic at 600 checks/s). In-process
|
| 243 |
-
# only: every shim restart starts cold.
|
| 244 |
-
VALIDATE_CACHE_SIZE = int(os.environ.get("VALIDATE_CACHE_SIZE", "4000000"))
|
| 245 |
-
_verdict_cache: "OrderedDict[str, object]" = OrderedDict()
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
def _cache_key(query: str, image_url: str, query_image_digest: str = "") -> str:
|
| 249 |
-
"""Content-keyed verdict-cache entry: the prompt, the PREPARED candidate
|
| 250 |
-
image and, for an image query, the query image's digest (every image query
|
| 251 |
-
shares one prompt, so without it two different query images would
|
| 252 |
-
coalesce)."""
|
| 253 |
-
h = hashlib.sha256()
|
| 254 |
-
for part in (RERANKER_MODEL, str(VALIDATE_MAX_EDGE), query, image_url, query_image_digest or ""):
|
| 255 |
-
h.update(part.encode())
|
| 256 |
-
h.update(b"\x00")
|
| 257 |
-
return h.hexdigest()
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
def _ref_key(prompt: str, query_image_digest: str, index: str, ref: str) -> str:
|
| 261 |
-
"""Reference-keyed verdict-cache entry for the batch path: keyed on the
|
| 262 |
-
IMAGE REFERENCE (image_key/asset_url + index slug) instead of prepared
|
| 263 |
-
bytes, so a cache hit costs no thumbnail fetch at all. The content key
|
| 264 |
-
(`_cache_key`) is still written alongside on every fresh verdict, so the
|
| 265 |
-
two paths coalesce. The query image participates as a PRE-COMPUTED digest
|
| 266 |
-
(image queries share one prompt and must not collide across different
|
| 267 |
-
reference images; hashing the multi-MB data URL per item would stall the
|
| 268 |
-
event loop; the route hashes it once)."""
|
| 269 |
-
h = hashlib.sha256()
|
| 270 |
-
for part in (RERANKER_MODEL, str(VALIDATE_MAX_EDGE), "ref", prompt, query_image_digest or "", index or "", ref):
|
| 271 |
-
h.update(part.encode())
|
| 272 |
-
h.update(b"\x00")
|
| 273 |
-
return h.hexdigest()
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
def _cache_get(key: str):
|
| 277 |
-
p = _verdict_cache.get(key)
|
| 278 |
-
if p is not None:
|
| 279 |
-
_verdict_cache.move_to_end(key)
|
| 280 |
-
return p
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
def _cache_put(key: str, score) -> None:
|
| 284 |
-
if VALIDATE_CACHE_SIZE <= 0:
|
| 285 |
-
return
|
| 286 |
-
_verdict_cache[key] = score
|
| 287 |
-
_verdict_cache.move_to_end(key)
|
| 288 |
-
while len(_verdict_cache) > VALIDATE_CACHE_SIZE:
|
| 289 |
-
_verdict_cache.popitem(last=False)
|
| 290 |
-
|
| 291 |
-
# Firm, one-word judge prompt (a weak prompt makes GLM describe the image → the
|
| 292 |
-
# logprob trick breaks). Text goes BEFORE the image in the message content.
|
| 293 |
-
JUDGE_PROMPT_HEAD = 'You are a visual relevance judge for a 3D-asset search. Query: "{query}". '
|
| 294 |
-
JUDGE_QUESTION = "Does the rendered asset match the query? Answer with exactly one word: Yes or No."
|
| 295 |
-
JUDGE_PROMPT = JUDGE_PROMPT_HEAD + JUDGE_QUESTION
|
| 296 |
-
# The asset facts (JUDGE_ASSET_CONTEXT), between the query and the question, as
|
| 297 |
-
# evaluated on 2026-10-07.
|
| 298 |
-
JUDGE_PATH_FACT = 'The candidate asset\'s file is "{path}". '
|
| 299 |
-
JUDGE_CAPTION_FACT = 'Catalog description: "{caption}". '
|
| 300 |
-
# An image alone (find-similar): usdsearch's own image-query question
|
| 301 |
-
# (llm_client LOGPROB_JUDGE_PROMPT_IMAGE_QUERY without its caption clause),
|
| 302 |
-
# word for word; the query image goes first. Without a dedicated prompt an
|
| 303 |
-
# image search would be scored against 'Query: ""' — pure noise. On 40 known
|
| 304 |
-
# pairs (2026-10-06) it matched the shim's earlier wording: every same-object
|
| 305 |
-
# pair Yes, every unrelated pair No.
|
| 306 |
-
JUDGE_IMAGE_PROMPT = (
|
| 307 |
-
"You are a visual relevance judge for a 3D-asset search. The first image is "
|
| 308 |
-
"the query image; the remaining images are rendered views of one asset. Does "
|
| 309 |
-
"the rendered asset match the query? Answer with exactly one word: Yes or No."
|
| 310 |
-
)
|
| 311 |
-
# An image WITH words: both count, the query image first. Not usdsearch's
|
| 312 |
-
# caption clause ('… the query image and the text query is "…"; the remaining
|
| 313 |
-
# images are rendered views of one asset'), which let the model judge the query
|
| 314 |
-
# image itself against the words: on the same 40 pairs it passed 3-4 unrelated
|
| 315 |
-
# candidates, this wording none (the words alone, the shim's earlier rule: 1).
|
| 316 |
-
JUDGE_IMAGE_TEXT_PROMPT = (
|
| 317 |
-
'You are a visual relevance judge for a 3D-asset search. Query: "{query}", '
|
| 318 |
-
"together with the first image, which shows what the user is looking for. "
|
| 319 |
-
"The other image shows a candidate asset. Does the candidate asset match the "
|
| 320 |
-
"query? Answer with exactly one word: Yes or No."
|
| 321 |
-
)
|
| 322 |
-
YES_TOKENS = {"yes", "y", "true"}
|
| 323 |
-
NO_TOKENS = {"no", "n", "false"}
|
| 324 |
-
|
| 325 |
-
# One pooled keep-alive client for the process (created in lifespan): validation
|
| 326 |
-
# is ~100 calls per search against a remote engine, and a per-request client
|
| 327 |
-
# pays a fresh TCP handshake (an RTT) on every call.
|
| 328 |
-
_reranker_client: Optional[httpx.AsyncClient] = None
|
| 329 |
-
# Separate, short-timeout client for shim-side thumbnail fetches (batch path):
|
| 330 |
-
# 50 image GETs must never compete with engine calls for the reranker pool,
|
| 331 |
-
# and one slow origin must cost 5 s, not the engine's 30.
|
| 332 |
-
_images_client: Optional[httpx.AsyncClient] = None
|
| 333 |
-
# In-flight cap at the engine's measured saturation point — a burst beyond it
|
| 334 |
-
# queues here instead of doubling everyone's latency engine-side.
|
| 335 |
-
_reranker_sem = asyncio.Semaphore(RERANKER_MAX_CONCURRENCY)
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
@asynccontextmanager
|
| 339 |
-
async def _lifespan(app: FastAPI):
|
| 340 |
-
global _reranker_client, _images_client
|
| 341 |
-
_reranker_client = httpx.AsyncClient(
|
| 342 |
-
timeout=TIMEOUT_S,
|
| 343 |
-
limits=httpx.Limits(max_connections=64, max_keepalive_connections=64),
|
| 344 |
-
)
|
| 345 |
-
_images_client = httpx.AsyncClient(
|
| 346 |
-
# pool=None: waiting for a connection SLOT must queue, not error — a
|
| 347 |
-
# scalar timeout also bounds pool acquisition, and N concurrent
|
| 348 |
-
# batches × 12 workers over a finite pool would otherwise fabricate
|
| 349 |
-
# PoolTimeout "image fetch failed" verdicts for perfectly good
|
| 350 |
-
# thumbnails under load.
|
| 351 |
-
timeout=httpx.Timeout(connect=IMAGES_TIMEOUT_S, read=IMAGES_TIMEOUT_S, write=IMAGES_TIMEOUT_S, pool=None),
|
| 352 |
-
limits=httpx.Limits(max_connections=64, max_keepalive_connections=64),
|
| 353 |
-
)
|
| 354 |
-
try:
|
| 355 |
-
yield
|
| 356 |
-
finally:
|
| 357 |
-
await _reranker_client.aclose()
|
| 358 |
-
_reranker_client = None
|
| 359 |
-
await _images_client.aclose()
|
| 360 |
-
_images_client = None
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
app = FastAPI(title="deeptag-validate", version="1.1.0", lifespan=_lifespan)
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
class ValidateRequest(BaseModel):
|
| 367 |
-
query_text: Optional[str] = None
|
| 368 |
-
query_image: Optional[str] = None
|
| 369 |
-
images: List[str] = []
|
| 370 |
-
# The asset's facts for words-only judging (JUDGE_ASSET_CONTEXT).
|
| 371 |
-
path: Optional[str] = None
|
| 372 |
-
caption: Optional[str] = None
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
def p_yes_from_logprobs(content):
|
| 376 |
-
"""Scan token positions for the one carrying Yes/No mass (scan-mode is the
|
| 377 |
-
contract: robust to wrapper tokens some models emit before the verdict);
|
| 378 |
-
return (p_yes, cover) or (None, 0) if uncertain."""
|
| 379 |
-
for pos in content or []:
|
| 380 |
-
yes = no = 0.0
|
| 381 |
-
for entry in pos.get("top_logprobs", []) or []:
|
| 382 |
-
token = (entry.get("token") or "").strip().lower()
|
| 383 |
-
prob = math.exp(entry["logprob"])
|
| 384 |
-
if token in YES_TOKENS:
|
| 385 |
-
yes += prob
|
| 386 |
-
elif token in NO_TOKENS:
|
| 387 |
-
no += prob
|
| 388 |
-
if yes + no > COVER_MIN:
|
| 389 |
-
return yes / (yes + no), yes + no
|
| 390 |
-
return None, 0.0
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
def _shrink_bytes(raw: bytes, src: str, was_data_url: bool) -> str:
|
| 394 |
-
"""Sync (thread-pooled) half of image prep: decode, downscale if the longest
|
| 395 |
-
edge exceeds VALIDATE_MAX_EDGE (LANCZOS, JPEG q90), return a data URL. An
|
| 396 |
-
already-small data URL passes through UNTOUCHED; an already-small fetched
|
| 397 |
-
image is re-encoded as a data URL so the engine doesn't re-fetch it."""
|
| 398 |
-
with Image.open(io.BytesIO(raw)) as img:
|
| 399 |
-
w, h = img.size
|
| 400 |
-
if max(w, h) <= VALIDATE_MAX_EDGE:
|
| 401 |
-
logger.debug("image %dx%d <= %dpx: pass-through", w, h, VALIDATE_MAX_EDGE)
|
| 402 |
-
if was_data_url:
|
| 403 |
-
return src
|
| 404 |
-
fmt = (img.format or "png").lower()
|
| 405 |
-
mime = "image/jpeg" if fmt in ("jpg", "jpeg") else f"image/{fmt}"
|
| 406 |
-
return f"data:{mime};base64," + base64.b64encode(raw).decode()
|
| 407 |
-
# A JPEG decodes straight at a reduced DCT scale (1/2 for a 1024-px
|
| 408 |
-
# thumbnail; never below the cap): about half the shim CPU of a full
|
| 409 |
-
# decode before the resize. Other formats ignore it.
|
| 410 |
-
img.draft(None, (VALIDATE_MAX_EDGE, VALIDATE_MAX_EDGE))
|
| 411 |
-
rgb = img.convert("RGB")
|
| 412 |
-
rgb.thumbnail((VALIDATE_MAX_EDGE, VALIDATE_MAX_EDGE), Image.LANCZOS)
|
| 413 |
-
buf = io.BytesIO()
|
| 414 |
-
rgb.save(buf, format="JPEG", quality=90)
|
| 415 |
-
logger.debug(
|
| 416 |
-
"image resized %dx%d -> %dx%d (%d -> %d bytes)",
|
| 417 |
-
w, h, rgb.width, rgb.height, len(raw), buf.tell(),
|
| 418 |
-
)
|
| 419 |
-
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
async def _prepare_image(src: str) -> str:
|
| 423 |
-
"""Downscale-and-inline an image reference before it reaches the engine.
|
| 424 |
-
Defensive: anything unparseable is sent through untouched — a weird image
|
| 425 |
-
must degrade to the engine's own handling, never break validation."""
|
| 426 |
-
try:
|
| 427 |
-
if src.startswith("data:"):
|
| 428 |
-
_, _, b64 = src.partition(",")
|
| 429 |
-
raw = base64.b64decode(b64)
|
| 430 |
-
was_data_url = True
|
| 431 |
-
elif src.startswith(("http://", "https://")):
|
| 432 |
-
resp = await _reranker_client.get(src)
|
| 433 |
-
resp.raise_for_status()
|
| 434 |
-
raw = resp.content
|
| 435 |
-
was_data_url = False
|
| 436 |
-
else:
|
| 437 |
-
# Bare base64: the Explorer strips the data: prefix off its query
|
| 438 |
-
# image. Sent as is, the engine reads it as an image URL and 400s
|
| 439 |
-
# every image-only verdict; inline it as a data URL instead.
|
| 440 |
-
# Non-base64 strings still pass through (strict decode raises).
|
| 441 |
-
raw = base64.b64decode(src, validate=True)
|
| 442 |
-
was_data_url = False
|
| 443 |
-
return await asyncio.to_thread(_shrink_bytes, raw, src, was_data_url)
|
| 444 |
-
except Exception as exc: # noqa: BLE001 — image prep must never break validation
|
| 445 |
-
logger.warning("image prep failed (%s: %s); sending original", type(exc).__name__, exc)
|
| 446 |
-
return src
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
def _judge_payload(prompt: str, image_url: str, query_image_url: str = None) -> dict:
|
| 450 |
-
# Text before image(s) per the judge contract. For image queries the query
|
| 451 |
-
# image precedes the candidate (the image prompts' "first image" wording
|
| 452 |
-
# depends on this order). Plain sampling parameters only:
|
| 453 |
-
# no structured-outputs grammar (that belonged to the retired graded
|
| 454 |
-
# method), so an engine without a grammar backend serves this unchanged.
|
| 455 |
-
content = [{"type": "text", "text": prompt}]
|
| 456 |
-
if query_image_url:
|
| 457 |
-
content.append({"type": "image_url", "image_url": {"url": query_image_url}})
|
| 458 |
-
content.append({"type": "image_url", "image_url": {"url": image_url}})
|
| 459 |
-
return {
|
| 460 |
-
"model": RERANKER_MODEL,
|
| 461 |
-
"temperature": 0,
|
| 462 |
-
"max_tokens": RERANKER_MAX_TOKENS,
|
| 463 |
-
"logprobs": True,
|
| 464 |
-
"top_logprobs": 20,
|
| 465 |
-
"chat_template_kwargs": {"enable_thinking": False},
|
| 466 |
-
"messages": [{"role": "user", "content": content}],
|
| 467 |
-
}
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
async def _call_reranker(payload: dict) -> dict:
|
| 471 |
-
# Transient-failure semantics from the validator contract: back off and
|
| 472 |
-
# retry ONCE on connect errors / engine 5xx before failing soft. The
|
| 473 |
-
# semaphore holds in-flight calls at the engine's saturation point.
|
| 474 |
-
for attempt in (1, 2):
|
| 475 |
-
try:
|
| 476 |
-
async with _reranker_sem:
|
| 477 |
-
resp = await _reranker_client.post(
|
| 478 |
-
f"{RERANKER_BASE_URL}/chat/completions", json=payload, headers=_ENGINE_HEADERS
|
| 479 |
-
)
|
| 480 |
-
resp.raise_for_status()
|
| 481 |
-
return resp.json()
|
| 482 |
-
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout):
|
| 483 |
-
if attempt == 1:
|
| 484 |
-
await asyncio.sleep(0.25)
|
| 485 |
-
continue
|
| 486 |
-
# Engine down / slow → fail-soft. The Explorer reads 503 as
|
| 487 |
-
# "VLM unavailable": banner, validation stops; search is unaffected.
|
| 488 |
-
raise HTTPException(status_code=503, detail="reranker unavailable")
|
| 489 |
-
except httpx.HTTPStatusError as exc:
|
| 490 |
-
if attempt == 1 and exc.response.status_code >= 500:
|
| 491 |
-
await asyncio.sleep(0.25)
|
| 492 |
-
continue
|
| 493 |
-
raise EngineRejected(exc.response.status_code, f"reranker error: {exc}")
|
| 494 |
-
except httpx.HTTPError as exc:
|
| 495 |
-
raise HTTPException(status_code=502, detail=f"reranker error: {exc}")
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
# The configuration refusal's message: fixed, never the engine's own error (it names the engine's URL).
|
| 499 |
-
IMAGE_QUERY_REFUSED = "the validation engine refused an image query (it may take one image per prompt)"
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
class EngineRejected(HTTPException):
|
| 503 |
-
"""The engine ANSWERED with an error status (a 502 to the client, as before; the batch route keeps
|
| 504 |
-
treating it per item). `engine_status` lets the per-image route tell a refused request shape apart."""
|
| 505 |
-
|
| 506 |
-
def __init__(self, engine_status: int, detail: str):
|
| 507 |
-
super().__init__(status_code=502, detail=detail)
|
| 508 |
-
self.engine_status = engine_status
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
def _abstain_verdict() -> dict:
|
| 512 |
-
# Persistent abstain → keep the candidate in retrieval order, unscored
|
| 513 |
-
# (is_match=true so the Explorer does not reject it; confidence 0.0 and
|
| 514 |
-
# abstain=true make the non-verdict explicit rather than a fabricated
|
| 515 |
-
# score).
|
| 516 |
-
return {"is_match": True, "confidence": 0.0, "similarity_score": 0.0, "abstain": True, "model": RERANKER_NAME}
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
def _verdict_from_score(score: float) -> dict:
|
| 520 |
-
"""The ONE place a verdict dict is built from a cached or fresh SCORE
|
| 521 |
-
RECORD (the P(Yes) float), for both routes and both cache paths — a
|
| 522 |
-
boundary that lived in three copies would silently regress on cache hits.
|
| 523 |
-
similarity_score is P(Yes) x 100 (usdsearch's scale); confidence stays
|
| 524 |
-
P(Yes)."""
|
| 525 |
-
return {
|
| 526 |
-
"is_match": score > 0.5,
|
| 527 |
-
"confidence": round(score, 4),
|
| 528 |
-
"similarity_score": round(score * 100, 2),
|
| 529 |
-
"model": RERANKER_NAME,
|
| 530 |
-
}
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
def _extract_score(data: dict):
|
| 534 |
-
"""Pull the P(Yes) score record out of one engine response, or None
|
| 535 |
-
(abstain)."""
|
| 536 |
-
content = ((data.get("choices") or [{}])[0].get("logprobs") or {}).get("content")
|
| 537 |
-
p_yes, _cover = p_yes_from_logprobs(content)
|
| 538 |
-
return p_yes
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
def _fact_text(value, limit: int, keep_tail: bool = False) -> str:
|
| 542 |
-
"""One asset fact as prompt text: whitespace collapsed, double quotes made
|
| 543 |
-
single (the fact sits inside a quoted span), at most `limit` characters."""
|
| 544 |
-
text = " ".join(str(value or "").split()).replace('"', "'")
|
| 545 |
-
if len(text) > limit:
|
| 546 |
-
text = text[-limit:] if keep_tail else text[:limit]
|
| 547 |
-
return text
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
_ISAAC_ROOT = re.compile(r"/Isaac/[^/]+/Isaac/(.+)$")
|
| 551 |
-
# A Hugging Face dataset file (the hf-* indexes, the HF Spaces): below the dataset, and below
|
| 552 |
-
# `resolve|blob/<revision>/` where the key pins one, e.g.
|
| 553 |
-
# hf://huggingface.co/datasets/nvidia/simready-assets/resolve/2464ca32…/fixture_island_a01/simready_usd/x.usd
|
| 554 |
-
# → fixture_island_a01/simready_usd/x.usd.
|
| 555 |
-
_HF_DATASET_ROOT = re.compile(
|
| 556 |
-
r"^(?:[a-z][a-z0-9+.-]*://)?huggingface\.co/datasets/[^/]+/[^/]+/(?:(?:resolve|blob)/[^/]+/)?(.+)$", re.I
|
| 557 |
-
)
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
def _asset_path(path) -> str:
|
| 561 |
-
"""The path as the judge reads it: below the release-agnostic Isaac content
|
| 562 |
-
root (`Robots/Unitree/H2/H2.usda`, as evaluated), or below a Hugging Face
|
| 563 |
-
dataset's root, else without its leading slash."""
|
| 564 |
-
p = str(path or "").strip()
|
| 565 |
-
m = _ISAAC_ROOT.search(p) or _HF_DATASET_ROOT.match(p)
|
| 566 |
-
return _fact_text(m.group(1) if m else p.lstrip("/"), ASSET_PATH_MAX, keep_tail=True)
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
def _asset_facts(path, caption) -> str:
|
| 570 |
-
"""The words-only prompt's asset facts under JUDGE_ASSET_CONTEXT, or "":
|
| 571 |
-
the file path, then (path+caption) the caption. No path → no facts, so
|
| 572 |
-
only the evaluated combinations are ever sent."""
|
| 573 |
-
if JUDGE_ASSET_CONTEXT == "off":
|
| 574 |
-
return ""
|
| 575 |
-
p = _asset_path(path)
|
| 576 |
-
if not p:
|
| 577 |
-
return ""
|
| 578 |
-
facts = JUDGE_PATH_FACT.format(path=p)
|
| 579 |
-
caption_text = _fact_text(caption, ASSET_CAPTION_MAX) if JUDGE_ASSET_CONTEXT == "path+caption" else ""
|
| 580 |
-
if caption_text:
|
| 581 |
-
facts += JUDGE_CAPTION_FACT.format(caption=caption_text)
|
| 582 |
-
return facts
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
def _select_prompt(query_text: str, has_query_image: bool, path=None, caption=None) -> str:
|
| 586 |
-
"""The judge prompt for one candidate, over the stripped query text: an
|
| 587 |
-
image with words → JUDGE_IMAGE_TEXT_PROMPT, an image alone →
|
| 588 |
-
JUDGE_IMAGE_PROMPT, words alone → JUDGE_PROMPT, with the candidate's facts
|
| 589 |
-
before the question when JUDGE_ASSET_CONTEXT is on (a request with neither
|
| 590 |
-
words nor image keeps the historical 'Query: ""' prompt)."""
|
| 591 |
-
text = (query_text or "").strip()
|
| 592 |
-
if has_query_image:
|
| 593 |
-
return JUDGE_IMAGE_TEXT_PROMPT.format(query=text) if text else JUDGE_IMAGE_PROMPT
|
| 594 |
-
facts = _asset_facts(path, caption)
|
| 595 |
-
if not facts:
|
| 596 |
-
return JUDGE_PROMPT.format(query=text)
|
| 597 |
-
return JUDGE_PROMPT_HEAD.format(query=text) + facts + JUDGE_QUESTION
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
def _query_image_digest(query_image: Optional[str]) -> str:
|
| 601 |
-
"""Hash the (possibly multi-MB) query image ONCE per request — the cache
|
| 602 |
-
keys run per item, and per-item sha256 over a full data URL would stall
|
| 603 |
-
the loop."""
|
| 604 |
-
return hashlib.sha256(query_image.encode()).hexdigest() if query_image else ""
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
async def _score_prepared(prompt: str, image_url: str, query_image_url: str = None):
|
| 608 |
-
"""One verdict for a PREPARED (downscaled, inlined) image. Returns
|
| 609 |
-
(score | None, verdict_dict); score None means persistent abstain (the
|
| 610 |
-
dict already carries abstain=true; the CALLER decides caching, and
|
| 611 |
-
abstains are never cached). The score is the cache record (see
|
| 612 |
-
_verdict_from_score). Raises HTTPException 502/503 when the engine is
|
| 613 |
-
unreachable (fail-soft to the Explorer's "VLM unavailable")."""
|
| 614 |
-
data = await _call_reranker(_judge_payload(prompt, image_url, query_image_url))
|
| 615 |
-
score = _extract_score(data)
|
| 616 |
-
if score is None:
|
| 617 |
-
# Abstain semantics: retry once — never treat an unreliable read as "No".
|
| 618 |
-
data = await _call_reranker(_judge_payload(prompt, image_url, query_image_url))
|
| 619 |
-
score = _extract_score(data)
|
| 620 |
-
if score is None:
|
| 621 |
-
return None, _abstain_verdict()
|
| 622 |
-
return score, _verdict_from_score(score)
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
@app.get("/health")
|
| 626 |
-
def health():
|
| 627 |
-
return {"status": "ok", "reranker": RERANKER_BASE_URL, "model": RERANKER_MODEL, "asset_context": JUDGE_ASSET_CONTEXT}
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
@app.post("/vlm_validate/search_result")
|
| 631 |
-
async def validate(req: ValidateRequest):
|
| 632 |
-
if not req.images:
|
| 633 |
-
raise HTTPException(status_code=400, detail="no image provided")
|
| 634 |
-
# The same judge as the batch route: a query image (the Explorer sends it
|
| 635 |
-
# bare base64 when it finishes a cut batch stream here) goes to the engine
|
| 636 |
-
# first, under the image prompts.
|
| 637 |
-
prompt = _select_prompt(req.query_text, bool(req.query_image), req.path, req.caption)
|
| 638 |
-
query_image_url = await _prepare_image(req.query_image) if req.query_image else None
|
| 639 |
-
image_url = await _prepare_image(req.images[0])
|
| 640 |
-
# Serve deterministic repeats from the verdict cache — no engine call. The
|
| 641 |
-
# key hashes the PREPARED image (post-resize), so the same asset arriving
|
| 642 |
-
# as a data URL or a fetched http URL coalesces once prepared.
|
| 643 |
-
ckey = _cache_key(prompt, image_url, _query_image_digest(req.query_image))
|
| 644 |
-
cached = _cache_get(ckey) if VALIDATE_CACHE_SIZE > 0 else None
|
| 645 |
-
if cached is not None:
|
| 646 |
-
return {**_verdict_from_score(cached), "cached": True}
|
| 647 |
-
try:
|
| 648 |
-
score, verdict = await _score_prepared(prompt, image_url, query_image_url)
|
| 649 |
-
except EngineRejected as exc:
|
| 650 |
-
# An engine that takes one image per prompt (the dev HF Space's NVCF function) answers an image
|
| 651 |
-
# query (query image + candidate) with a 4xx. Nothing is down: answer usdsearch's configuration
|
| 652 |
-
# refusal, which the Space's Explorer reads as "this search goes unvalidated" instead of the
|
| 653 |
-
# VLM-unavailable banner. Central's Explorer reads any 503 as before.
|
| 654 |
-
if query_image_url and 400 <= exc.engine_status < 500 and exc.engine_status not in (408, 429):
|
| 655 |
-
raise HTTPException(
|
| 656 |
-
status_code=503,
|
| 657 |
-
detail={"error_type": "configuration", "retryable": False, "message": IMAGE_QUERY_REFUSED},
|
| 658 |
-
) from exc
|
| 659 |
-
raise
|
| 660 |
-
if score is not None:
|
| 661 |
-
_cache_put(ckey, score)
|
| 662 |
-
return verdict
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
class BatchItem(BaseModel):
|
| 666 |
-
id: str
|
| 667 |
-
image_key: Optional[str] = None
|
| 668 |
-
asset_url: Optional[str] = None
|
| 669 |
-
# The hit's source.path and the index's caption for it: facts for
|
| 670 |
-
# words-only judging (JUDGE_ASSET_CONTEXT).
|
| 671 |
-
path: Optional[str] = None
|
| 672 |
-
caption: Optional[str] = None
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
class BatchValidateRequest(BaseModel):
|
| 676 |
-
query_text: Optional[str] = None
|
| 677 |
-
query_image: Optional[str] = None
|
| 678 |
-
# The index SLUG (x-usdsearch-storage-backend value) the shim forwards on
|
| 679 |
-
# its /images fetches — routing, api-key injection, and the shared image
|
| 680 |
-
# cache all key off it upstream. Empty → the gateway's default index.
|
| 681 |
-
index: Optional[str] = None
|
| 682 |
-
# In the client's DISPLAY (retrieval) order — dispatch preserves it, so
|
| 683 |
-
# the most-likely matches score first and the ranked flow fills top-down.
|
| 684 |
-
items: List[BatchItem] = []
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
def _ndjson(obj) -> str:
|
| 688 |
-
return json.dumps(obj, separators=(",", ":")) + "\n"
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
@app.post("/vlm_validate/batch_stream")
|
| 692 |
-
async def validate_batch(req: BatchValidateRequest):
|
| 693 |
-
"""Streamed batch validation (NDJSON). Line protocol:
|
| 694 |
-
|
| 695 |
-
{"id", "cached", "verdict": {is_match, confidence, similarity_score,
|
| 696 |
-
model[, abstain]}} — one per scored hit
|
| 697 |
-
{"id", "error": "..."} — per-hit failure (bad thumbnail etc.);
|
| 698 |
-
NEVER poisons the rest of the batch
|
| 699 |
-
"" — blank heartbeat every BATCH_HEARTBEAT_S
|
| 700 |
-
{"fatal": "..."} — engine unreachable; treat as 502/503
|
| 701 |
-
{"done": {completed, errored, cached}} — ALWAYS last on a clean finish;
|
| 702 |
-
its absence means the stream was cut
|
| 703 |
-
(the client re-POSTs the missing ids)
|
| 704 |
-
|
| 705 |
-
Shim-cache hits flush first (no engine, no image fetch — the reference-
|
| 706 |
-
keyed entries); misses dispatch in request order under the per-batch cap.
|
| 707 |
-
Client disconnect cancels every pending worker; `async with _reranker_sem`
|
| 708 |
-
releases on cancellation, so an abandoned query frees its engine slots."""
|
| 709 |
-
if not req.items:
|
| 710 |
-
raise HTTPException(status_code=400, detail="no items")
|
| 711 |
-
if len(req.items) > BATCH_MAX_ITEMS:
|
| 712 |
-
raise HTTPException(status_code=413, detail=f"batch exceeds {BATCH_MAX_ITEMS} items")
|
| 713 |
-
if not (req.query_text or req.query_image):
|
| 714 |
-
raise HTTPException(status_code=400, detail="no query")
|
| 715 |
-
index = (req.index or "").strip()
|
| 716 |
-
qimg_digest = _query_image_digest(req.query_image)
|
| 717 |
-
# An image query (alone or with words): the judge sees the query image
|
| 718 |
-
# first, then each candidate. Prepared (downscaled) once. With asset facts
|
| 719 |
-
# on, the prompt (and so every cache key) is per item.
|
| 720 |
-
def prompt_for(it: BatchItem) -> str:
|
| 721 |
-
return _select_prompt(req.query_text, bool(req.query_image), it.path, it.caption)
|
| 722 |
-
|
| 723 |
-
query_image_url = await _prepare_image(req.query_image) if req.query_image else None
|
| 724 |
-
|
| 725 |
-
# Phase 0 — reference-keyed cache pass: free verdicts, emitted first.
|
| 726 |
-
cached_lines = [] # (kind, line) — kind: "verdict" | "error"
|
| 727 |
-
misses = []
|
| 728 |
-
for it in req.items:
|
| 729 |
-
ref = it.image_key or it.asset_url
|
| 730 |
-
if not ref:
|
| 731 |
-
cached_lines.append(("error", _ndjson({"id": it.id, "error": "no image reference"})))
|
| 732 |
-
continue
|
| 733 |
-
cached = _cache_get(_ref_key(prompt_for(it), qimg_digest, index, ref)) if VALIDATE_CACHE_SIZE > 0 else None
|
| 734 |
-
if cached is not None:
|
| 735 |
-
cached_lines.append(
|
| 736 |
-
("verdict", _ndjson({
|
| 737 |
-
"id": it.id,
|
| 738 |
-
"cached": True,
|
| 739 |
-
"verdict": _verdict_from_score(cached),
|
| 740 |
-
}))
|
| 741 |
-
)
|
| 742 |
-
else:
|
| 743 |
-
misses.append(it)
|
| 744 |
-
|
| 745 |
-
queue: asyncio.Queue = asyncio.Queue()
|
| 746 |
-
batch_sem = asyncio.Semaphore(RERANKER_BATCH_CONCURRENCY)
|
| 747 |
-
|
| 748 |
-
async def worker(it: BatchItem):
|
| 749 |
-
async with batch_sem:
|
| 750 |
-
ref = it.image_key or it.asset_url
|
| 751 |
-
prompt = prompt_for(it)
|
| 752 |
-
# Pinned base + urlencoded params ONLY — a reference can never
|
| 753 |
-
# change the request target (no SSRF surface); image_key (a
|
| 754 |
-
# specific matched view) wins over asset_url, mirroring the
|
| 755 |
-
# Explorer's own /images precedence.
|
| 756 |
-
params = {"image_key": it.image_key} if it.image_key else {"asset_url": it.asset_url}
|
| 757 |
-
headers = {"x-usdsearch-storage-backend": index} if index else {}
|
| 758 |
-
try:
|
| 759 |
-
resp = await _images_client.get(f"{SHIM_GATEWAY_BASE_URL}/images", params=params, headers=headers)
|
| 760 |
-
resp.raise_for_status()
|
| 761 |
-
raw = resp.content
|
| 762 |
-
except Exception as exc: # noqa: BLE001 — one bad thumbnail must not poison the batch
|
| 763 |
-
await queue.put(("error", _ndjson({"id": it.id, "error": f"image fetch failed: {type(exc).__name__}"})))
|
| 764 |
-
return
|
| 765 |
-
try:
|
| 766 |
-
# Raw bytes straight into the thread-pooled shrink: no base64
|
| 767 |
-
# round-trip on the loop, and the data URL's mime comes from
|
| 768 |
-
# PIL's own sniff (img.format), never the upstream's
|
| 769 |
-
# content-type header (S3-ish origins say octet-stream, which
|
| 770 |
-
# OpenAI-compatible engines 400 on).
|
| 771 |
-
image_url = await asyncio.to_thread(_shrink_bytes, raw, "", False)
|
| 772 |
-
except Exception as exc: # noqa: BLE001 — undecodable bytes must not reach the engine
|
| 773 |
-
await queue.put(("error", _ndjson({"id": it.id, "error": f"image decode failed: {type(exc).__name__}"})))
|
| 774 |
-
return
|
| 775 |
-
try:
|
| 776 |
-
score, verdict = await _score_prepared(prompt, image_url, query_image_url)
|
| 777 |
-
except HTTPException as exc:
|
| 778 |
-
if exc.status_code == 503:
|
| 779 |
-
# Engine UNREACHABLE — the whole batch is over.
|
| 780 |
-
await queue.put(("fatal", _ndjson({"fatal": exc.detail})))
|
| 781 |
-
else:
|
| 782 |
-
# Engine rejected THIS item (4xx→502: e.g. an image it
|
| 783 |
-
# can't decode) — per-hit, never batch-fatal.
|
| 784 |
-
await queue.put(("error", _ndjson({"id": it.id, "error": f"engine rejected item: {exc.detail}"})))
|
| 785 |
-
return
|
| 786 |
-
except Exception as exc: # noqa: BLE001 — a dead worker with no line would
|
| 787 |
-
# strand `remaining` and heartbeat the stream FOREVER (the
|
| 788 |
-
# blank lines reset every downstream timeout). Anything
|
| 789 |
-
# unexpected (non-JSON 200 from a proxy, logprob shape drift)
|
| 790 |
-
# settles the hit as a per-hit error instead.
|
| 791 |
-
logger.warning("batch worker failed (%s: %s)", type(exc).__name__, exc)
|
| 792 |
-
await queue.put(("error", _ndjson({"id": it.id, "error": f"scoring failed: {type(exc).__name__}"})))
|
| 793 |
-
return
|
| 794 |
-
if score is not None:
|
| 795 |
-
_cache_put(_ref_key(prompt, qimg_digest, index, ref), score)
|
| 796 |
-
_cache_put(_cache_key(prompt, image_url, qimg_digest), score)
|
| 797 |
-
await queue.put(("verdict", _ndjson({"id": it.id, "cached": False, "verdict": verdict})))
|
| 798 |
-
|
| 799 |
-
async def stream():
|
| 800 |
-
completed = sum(1 for kind, _ in cached_lines if kind == "verdict")
|
| 801 |
-
errored = len(cached_lines) - completed
|
| 802 |
-
cached_count = completed
|
| 803 |
-
# Workers are created INSIDE the generator: a client that aborts
|
| 804 |
-
# before the body is ever iterated (POST → immediate abort) must not
|
| 805 |
-
# leak a batch onto the shared engine — a never-started generator
|
| 806 |
-
# never runs its finally, so task lifetime has to live in this frame.
|
| 807 |
-
# Dispatch in REQUEST order — the per-batch semaphore wakes FIFO, so
|
| 808 |
-
# completion approximates retrieval order and the flow's top fills
|
| 809 |
-
# first.
|
| 810 |
-
tasks = [asyncio.create_task(worker(it)) for it in misses]
|
| 811 |
-
try:
|
| 812 |
-
for _, ln in cached_lines:
|
| 813 |
-
yield ln
|
| 814 |
-
remaining = len(tasks)
|
| 815 |
-
while remaining > 0:
|
| 816 |
-
try:
|
| 817 |
-
kind, ln = await asyncio.wait_for(queue.get(), timeout=BATCH_HEARTBEAT_S)
|
| 818 |
-
except asyncio.TimeoutError:
|
| 819 |
-
yield "\n" # heartbeat — keeps proxies from severing a quiet stream
|
| 820 |
-
continue
|
| 821 |
-
yield ln
|
| 822 |
-
if kind == "fatal":
|
| 823 |
-
return # finally cancels the rest; no `done` line on a fatal
|
| 824 |
-
remaining -= 1
|
| 825 |
-
if kind == "error":
|
| 826 |
-
errored += 1
|
| 827 |
-
else:
|
| 828 |
-
completed += 1
|
| 829 |
-
yield _ndjson({"done": {"completed": completed, "errored": errored, "cached": cached_count}})
|
| 830 |
-
finally:
|
| 831 |
-
# Client gone, fatal, or clean finish — nothing may keep burning
|
| 832 |
-
# shared engine slots. Cancellation releases the semaphores.
|
| 833 |
-
for t in tasks:
|
| 834 |
-
t.cancel()
|
| 835 |
-
|
| 836 |
-
return StreamingResponse(stream(), media_type="application/x-ndjson")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docker/hf-space/hf-storage-api/SOURCE.txt
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
# Generated by scripts/extract_hf_storage_api.sh — do not hand-edit.
|
| 2 |
-
source_image=nvcr.io/m3sujtetvf5w/usdsearch/hf-storage-api:
|
| 3 |
-
extracted_at=2026-
|
|
|
|
| 1 |
# Generated by scripts/extract_hf_storage_api.sh — do not hand-edit.
|
| 2 |
+
source_image=nvcr.io/m3sujtetvf5w/usdsearch/hf-storage-api:417d9af
|
| 3 |
+
extracted_at=2026-10-08T22:52:12Z
|
docker/hf-space/hf-storage-api/constraints-hf-storage-api.txt
CHANGED
|
@@ -10,10 +10,10 @@ httpx==0.28.1
|
|
| 10 |
idna==3.20
|
| 11 |
packaging==26.3
|
| 12 |
protobuf==7.36.2
|
| 13 |
-
pydantic==2.
|
| 14 |
-
pydantic_core==2.
|
| 15 |
pydantic-settings==2.15.0
|
| 16 |
-
python-dotenv==1.2.
|
| 17 |
PyYAML==6.0.3
|
| 18 |
typing_extensions==4.16.0
|
| 19 |
typing-inspection==0.4.4
|
|
|
|
| 10 |
idna==3.20
|
| 11 |
packaging==26.3
|
| 12 |
protobuf==7.36.2
|
| 13 |
+
pydantic==2.14.0
|
| 14 |
+
pydantic_core==2.50.0
|
| 15 |
pydantic-settings==2.15.0
|
| 16 |
+
python-dotenv==1.2.4
|
| 17 |
PyYAML==6.0.3
|
| 18 |
typing_extensions==4.16.0
|
| 19 |
typing-inspection==0.4.4
|
docker/hf-space/hf-storage-api/hf_storage_api/addressing.py
CHANGED
|
@@ -32,6 +32,13 @@ INBOUND_SCHEMES = frozenset({"hf", "https", "http"})
|
|
| 32 |
|
| 33 |
DEFAULT_SCHEME = "https"
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
@dataclass(frozen=True)
|
| 37 |
class Address:
|
|
@@ -136,10 +143,17 @@ def build(
|
|
| 136 |
a fetchable HF URL — HF requires a ``/resolve/<rev>/`` or ``/blob/<rev>/`` segment.
|
| 137 |
It still round-trips through :func:`parse`, which is what this service needs of it;
|
| 138 |
see the plan's §6.1 on pairing ``https`` with ``URI_REVISION_MODE``.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
"""
|
| 140 |
prefix = REPO_TYPE_PREFIX.get(repo_type, "")
|
| 141 |
ref = f"resolve/{revision}" if revision else ""
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
| 143 |
out = f"{scheme}://{host}/" + "/".join(parts)
|
| 144 |
if is_folder and not out.endswith("/"):
|
| 145 |
out += "/"
|
|
|
|
| 32 |
|
| 33 |
DEFAULT_SCHEME = "https"
|
| 34 |
|
| 35 |
+
# The only characters `build()` escapes: the ones that do not survive a round
|
| 36 |
+
# trip through `parse()` raw — `%` because `parse()` unquotes, `#` and `?`
|
| 37 |
+
# because `urlsplit` cuts the path at them. Escaping anything more (`@`, space,
|
| 38 |
+
# non-ASCII) would change addresses USD Search has already indexed, and those
|
| 39 |
+
# round-trip fine as they are. `%` goes first so the others are not re-escaped.
|
| 40 |
+
_ADDRESS_ESCAPES = (("%", "%25"), ("#", "%23"), ("?", "%3F"))
|
| 41 |
+
|
| 42 |
|
| 43 |
@dataclass(frozen=True)
|
| 44 |
class Address:
|
|
|
|
| 143 |
a fetchable HF URL — HF requires a ``/resolve/<rev>/`` or ``/blob/<rev>/`` segment.
|
| 144 |
It still round-trips through :func:`parse`, which is what this service needs of it;
|
| 145 |
see the plan's §6.1 on pairing ``https`` with ``URI_REVISION_MODE``.
|
| 146 |
+
|
| 147 |
+
``path`` is escaped just enough to round-trip (see ``_ADDRESS_ESCAPES``): a file
|
| 148 |
+
named ``Package%401.0.0.json`` is emitted as ``Package%25401.0.0.json``, which
|
| 149 |
+
is also the form the Hub itself needs.
|
| 150 |
"""
|
| 151 |
prefix = REPO_TYPE_PREFIX.get(repo_type, "")
|
| 152 |
ref = f"resolve/{revision}" if revision else ""
|
| 153 |
+
path = path.strip("/")
|
| 154 |
+
for char, escaped in _ADDRESS_ESCAPES:
|
| 155 |
+
path = path.replace(char, escaped)
|
| 156 |
+
parts = [p for p in (prefix, repo_id, ref, path) if p]
|
| 157 |
out = f"{scheme}://{host}/" + "/".join(parts)
|
| 158 |
if is_folder and not out.endswith("/"):
|
| 159 |
out += "/"
|
docker/hf-space/hf-storage-api/hf_storage_api/catalog/loader.py
CHANGED
|
@@ -10,6 +10,14 @@ blipped.
|
|
| 10 |
Every transport is polled, not just ``url``: a catalog file is mounted from a
|
| 11 |
ConfigMap or written by the catalog-sync sidecar, and both change under a running
|
| 12 |
process (sidecar plan §3).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
@@ -17,6 +25,7 @@ from __future__ import annotations
|
|
| 17 |
import asyncio
|
| 18 |
import hashlib
|
| 19 |
import logging
|
|
|
|
| 20 |
from pathlib import Path
|
| 21 |
|
| 22 |
import httpx
|
|
@@ -24,7 +33,7 @@ import yaml
|
|
| 24 |
|
| 25 |
from ..hf.client import HFClient
|
| 26 |
from ..settings import Settings
|
| 27 |
-
from .model import Catalog, LayoutConfig, RepoConfig
|
| 28 |
from .urllist import UrlListError, looks_like_url_list, normalize_asset_urls, parse_url_list
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
|
@@ -65,41 +74,71 @@ def parse_text(text: str, *, fmt: str = "auto", layout: LayoutConfig | None = No
|
|
| 65 |
return parse_catalog(text)
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
class CatalogLoader:
|
| 69 |
"""Loads the catalog and keeps it fresh, holding the last good copy."""
|
| 70 |
|
| 71 |
def __init__(self, settings: Settings, hf: HFClient) -> None:
|
| 72 |
self._settings = settings
|
| 73 |
self._hf = hf
|
| 74 |
-
self._etag: str | None = None
|
| 75 |
self._last_good: Catalog | None = None
|
| 76 |
self._task: asyncio.Task | None = None
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
|
| 82 |
@property
|
| 83 |
def current(self) -> Catalog | None:
|
| 84 |
return self._last_good
|
| 85 |
|
| 86 |
async def load(self) -> Catalog:
|
| 87 |
-
"""Load once. Raises on the first load; later failures keep the last good.
|
| 88 |
-
try:
|
| 89 |
-
text = await self._fetch_text()
|
| 90 |
-
if text is None: # 304 Not Modified
|
| 91 |
-
return self._last_good # type: ignore[return-value]
|
| 92 |
-
catalog = self._parse(text)
|
| 93 |
-
except Exception as exc:
|
| 94 |
-
if self._last_good is None:
|
| 95 |
-
raise
|
| 96 |
-
logger.warning("catalog refresh failed (%s); keeping the last good copy", exc)
|
| 97 |
-
return self._last_good
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
validate_catalog(catalog)
|
|
|
|
| 103 |
self._last_good = catalog
|
| 104 |
logger.info(
|
| 105 |
"catalog loaded: %d repo(s), %d asset(s)",
|
|
@@ -108,24 +147,50 @@ class CatalogLoader:
|
|
| 108 |
)
|
| 109 |
return catalog
|
| 110 |
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
"""Fetch the raw catalog. ``None`` means "unchanged since last time"."""
|
| 113 |
-
source
|
| 114 |
-
|
| 115 |
-
return self._read_file()
|
| 116 |
|
| 117 |
-
headers = {"If-None-Match":
|
| 118 |
async with httpx.AsyncClient(timeout=self._settings.http_timeout) as client:
|
| 119 |
response = await client.get(self._settings.catalog_url or "", headers=headers)
|
| 120 |
-
if response.status_code == 304 and
|
| 121 |
logger.debug("catalog unchanged (304)")
|
| 122 |
return None
|
| 123 |
response.raise_for_status()
|
| 124 |
-
|
| 125 |
return response.text
|
| 126 |
|
| 127 |
-
def _read_file(self) -> str | None:
|
| 128 |
-
"""Read
|
| 129 |
|
| 130 |
Two gates, because they answer different questions. ``stat()`` is what
|
| 131 |
makes polling free — it is the only syscall in the steady state. The
|
|
@@ -137,23 +202,20 @@ class CatalogLoader:
|
|
| 137 |
bytes that failed to parse must not be retried every interval, or one
|
| 138 |
bad file becomes one log line per tick forever.
|
| 139 |
"""
|
| 140 |
-
path =
|
| 141 |
-
if path is None: # pragma: no cover - Settings validates this
|
| 142 |
-
raise CatalogError("catalog_path is unset")
|
| 143 |
-
path = Path(path)
|
| 144 |
|
| 145 |
info = path.stat()
|
| 146 |
stamp = (info.st_mtime_ns, info.st_size)
|
| 147 |
-
if
|
| 148 |
return None
|
| 149 |
-
|
| 150 |
|
| 151 |
text = path.read_text(encoding="utf-8")
|
| 152 |
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
| 153 |
-
if
|
| 154 |
logger.debug("catalog file touched but unchanged (%s)", path)
|
| 155 |
return None
|
| 156 |
-
|
| 157 |
return text
|
| 158 |
|
| 159 |
def _parse(self, text: str) -> Catalog:
|
|
@@ -219,9 +281,10 @@ class CatalogLoader:
|
|
| 219 |
was silently ignored until the pod restarted — the asset set stayed at
|
| 220 |
whatever it was at boot while everything upstream reported success.
|
| 221 |
|
| 222 |
-
``discover`` re-walks
|
| 223 |
-
|
| 224 |
-
|
|
|
|
| 225 |
|
| 226 |
Polling rather than watching: ``stat()`` behaves the same on an
|
| 227 |
``emptyDir`` file replaced by ``os.replace``, on a ConfigMap's symlink
|
|
@@ -259,6 +322,26 @@ class CatalogLoader:
|
|
| 259 |
self._task = None
|
| 260 |
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
def validate_catalog(catalog: Catalog) -> None:
|
| 263 |
"""Reject a catalog that would serve nothing, or serve it ambiguously.
|
| 264 |
|
|
@@ -269,14 +352,25 @@ def validate_catalog(catalog: Catalog) -> None:
|
|
| 269 |
"""
|
| 270 |
if not catalog.repos:
|
| 271 |
raise CatalogError("catalog lists no repos")
|
| 272 |
-
|
| 273 |
for repo in catalog.repos:
|
| 274 |
if "/" not in repo.repo_id:
|
| 275 |
raise CatalogError(f"repo_id must be <owner>/<name>: {repo.repo_id!r}")
|
| 276 |
-
if repo.
|
| 277 |
-
raise CatalogError(f"duplicate repo in catalog: {repo.repo_id!r}")
|
| 278 |
-
|
| 279 |
if not repo.assets and repo.discover is None:
|
| 280 |
raise CatalogError(
|
| 281 |
f"repo {repo.repo_id!r} lists no assets and no discover block — it would serve nothing"
|
| 282 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
Every transport is polled, not just ``url``: a catalog file is mounted from a
|
| 11 |
ConfigMap or written by the catalog-sync sidecar, and both change under a running
|
| 12 |
process (sidecar plan §3).
|
| 13 |
+
|
| 14 |
+
The primary catalog may be joined by *extra* catalog files
|
| 15 |
+
(``HF_ADAPTER_CATALOG_EXTRA_PATHS``) — in practice a YAML catalog that walks some
|
| 16 |
+
repos, plus a URL list the sidecar keeps in step with a GitHub submission file.
|
| 17 |
+
Each source is read, discovered and guarded on its own and keeps its own last
|
| 18 |
+
good copy, so a broken sync leaves the YAML repos serving and a changed URL list
|
| 19 |
+
does not re-walk them. The results are merged into one catalog: still one access
|
| 20 |
+
boundary, still one repo per key.
|
| 21 |
"""
|
| 22 |
|
| 23 |
from __future__ import annotations
|
|
|
|
| 25 |
import asyncio
|
| 26 |
import hashlib
|
| 27 |
import logging
|
| 28 |
+
from dataclasses import dataclass
|
| 29 |
from pathlib import Path
|
| 30 |
|
| 31 |
import httpx
|
|
|
|
| 33 |
|
| 34 |
from ..hf.client import HFClient
|
| 35 |
from ..settings import Settings
|
| 36 |
+
from .model import Catalog, LayoutConfig, RepoConfig, looks_like_sha
|
| 37 |
from .urllist import UrlListError, looks_like_url_list, normalize_asset_urls, parse_url_list
|
| 38 |
|
| 39 |
logger = logging.getLogger(__name__)
|
|
|
|
| 74 |
return parse_catalog(text)
|
| 75 |
|
| 76 |
|
| 77 |
+
@dataclass
|
| 78 |
+
class _Source:
|
| 79 |
+
"""One catalog input and what it looked like last time it was read."""
|
| 80 |
+
|
| 81 |
+
path: Path | None # None: the primary catalog, fetched from `catalog_url`
|
| 82 |
+
primary: bool
|
| 83 |
+
etag: str | None = None
|
| 84 |
+
# A cheap (mtime, size) gate in front of an authoritative content digest.
|
| 85 |
+
stat: tuple[int, int] | None = None
|
| 86 |
+
digest: str | None = None
|
| 87 |
+
# Parsed, discovered and guarded — what this source contributes right now.
|
| 88 |
+
last_good: Catalog | None = None
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def name(self) -> str:
|
| 92 |
+
return str(self.path) if self.path is not None else "catalog URL"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
class CatalogLoader:
|
| 96 |
"""Loads the catalog and keeps it fresh, holding the last good copy."""
|
| 97 |
|
| 98 |
def __init__(self, settings: Settings, hf: HFClient) -> None:
|
| 99 |
self._settings = settings
|
| 100 |
self._hf = hf
|
|
|
|
| 101 |
self._last_good: Catalog | None = None
|
| 102 |
self._task: asyncio.Task | None = None
|
| 103 |
+
primary_path = None if settings.catalog_source == "url" else settings.catalog_path
|
| 104 |
+
self._sources = [_Source(primary_path, primary=True)] + [
|
| 105 |
+
_Source(path, primary=False) for path in settings.extra_catalog_paths
|
| 106 |
+
]
|
| 107 |
|
| 108 |
@property
|
| 109 |
def current(self) -> Catalog | None:
|
| 110 |
return self._last_good
|
| 111 |
|
| 112 |
async def load(self) -> Catalog:
|
| 113 |
+
"""Load once. Raises on the first load; later failures keep the last good.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
"Last good" is kept per source: one broken input holds its own previous
|
| 116 |
+
contribution while the others still update.
|
| 117 |
+
"""
|
| 118 |
+
parts: list[Catalog] = []
|
| 119 |
+
changed = False
|
| 120 |
+
for source in self._sources:
|
| 121 |
+
try:
|
| 122 |
+
part = await self._load_source(source)
|
| 123 |
+
except Exception as exc:
|
| 124 |
+
if source.last_good is None:
|
| 125 |
+
raise
|
| 126 |
+
logger.warning(
|
| 127 |
+
"catalog refresh failed for %s (%s); keeping its last good copy", source.name, exc
|
| 128 |
+
)
|
| 129 |
+
part = None
|
| 130 |
+
if part is not None:
|
| 131 |
+
changed = True
|
| 132 |
+
parts.append(source.last_good) # type: ignore[arg-type] - set by now
|
| 133 |
|
| 134 |
+
if not changed and self._last_good is not None:
|
| 135 |
+
return self._last_good
|
| 136 |
+
|
| 137 |
+
catalog = merge_catalogs(parts)
|
| 138 |
+
# Per-source checks cannot see across sources: the same repo named by the
|
| 139 |
+
# YAML catalog and by the synced list is only visible here.
|
| 140 |
validate_catalog(catalog)
|
| 141 |
+
self._check_revisions_are_addressable(catalog)
|
| 142 |
self._last_good = catalog
|
| 143 |
logger.info(
|
| 144 |
"catalog loaded: %d repo(s), %d asset(s)",
|
|
|
|
| 147 |
)
|
| 148 |
return catalog
|
| 149 |
|
| 150 |
+
def _check_revisions_are_addressable(self, catalog: Catalog) -> None:
|
| 151 |
+
"""Several revisions of one repo are told apart only by the
|
| 152 |
+
``/resolve/<sha>/`` segment in their addresses — which ``never`` drops."""
|
| 153 |
+
if self._settings.uri_revision_mode != "never":
|
| 154 |
+
return
|
| 155 |
+
keys = [repo.key for repo in catalog.repos]
|
| 156 |
+
if len(keys) != len(set(keys)):
|
| 157 |
+
raise CatalogError(
|
| 158 |
+
"the catalog serves a repo at several revisions, which needs the revision in every "
|
| 159 |
+
"address — HF_ADAPTER_URI_REVISION_MODE=never removes it"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
async def _load_source(self, source: _Source) -> Catalog | None:
|
| 163 |
+
"""Refresh one source. ``None`` means "unchanged since last time"."""
|
| 164 |
+
text = await self._fetch_text(source)
|
| 165 |
+
if text is None:
|
| 166 |
+
return None
|
| 167 |
+
catalog = self._parse(text)
|
| 168 |
+
|
| 169 |
+
discover = source.primary and self._settings.catalog_source == "discover"
|
| 170 |
+
if discover or any(r.discover is not None for r in catalog.repos):
|
| 171 |
+
catalog = await self._discover(catalog)
|
| 172 |
+
|
| 173 |
+
validate_catalog(catalog)
|
| 174 |
+
source.last_good = catalog
|
| 175 |
+
return catalog
|
| 176 |
+
|
| 177 |
+
async def _fetch_text(self, source: _Source) -> str | None:
|
| 178 |
"""Fetch the raw catalog. ``None`` means "unchanged since last time"."""
|
| 179 |
+
if source.path is not None:
|
| 180 |
+
return self._read_file(source)
|
|
|
|
| 181 |
|
| 182 |
+
headers = {"If-None-Match": source.etag} if source.etag else {}
|
| 183 |
async with httpx.AsyncClient(timeout=self._settings.http_timeout) as client:
|
| 184 |
response = await client.get(self._settings.catalog_url or "", headers=headers)
|
| 185 |
+
if response.status_code == 304 and source.last_good is not None:
|
| 186 |
logger.debug("catalog unchanged (304)")
|
| 187 |
return None
|
| 188 |
response.raise_for_status()
|
| 189 |
+
source.etag = response.headers.get("ETag")
|
| 190 |
return response.text
|
| 191 |
|
| 192 |
+
def _read_file(self, source: _Source) -> str | None:
|
| 193 |
+
"""Read a catalog file, or ``None`` when its contents have not changed.
|
| 194 |
|
| 195 |
Two gates, because they answer different questions. ``stat()`` is what
|
| 196 |
makes polling free — it is the only syscall in the steady state. The
|
|
|
|
| 202 |
bytes that failed to parse must not be retried every interval, or one
|
| 203 |
bad file becomes one log line per tick forever.
|
| 204 |
"""
|
| 205 |
+
path = Path(source.path) # type: ignore[arg-type] - only called for files
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
info = path.stat()
|
| 208 |
stamp = (info.st_mtime_ns, info.st_size)
|
| 209 |
+
if source.last_good is not None and stamp == source.stat:
|
| 210 |
return None
|
| 211 |
+
source.stat = stamp
|
| 212 |
|
| 213 |
text = path.read_text(encoding="utf-8")
|
| 214 |
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
| 215 |
+
if source.last_good is not None and digest == source.digest:
|
| 216 |
logger.debug("catalog file touched but unchanged (%s)", path)
|
| 217 |
return None
|
| 218 |
+
source.digest = digest
|
| 219 |
return text
|
| 220 |
|
| 221 |
def _parse(self, text: str) -> Catalog:
|
|
|
|
| 281 |
was silently ignored until the pod restarted — the asset set stayed at
|
| 282 |
whatever it was at boot while everything upstream reported success.
|
| 283 |
|
| 284 |
+
``discover`` re-walks a repo only when the file that names it changed —
|
| 285 |
+
an extra catalog file changing does not re-walk the primary's repos. A
|
| 286 |
+
repo-wide tree read on a timer is a much bigger promise than this loop
|
| 287 |
+
should make on its own (plan §5.2).
|
| 288 |
|
| 289 |
Polling rather than watching: ``stat()`` behaves the same on an
|
| 290 |
``emptyDir`` file replaced by ``os.replace``, on a ConfigMap's symlink
|
|
|
|
| 322 |
self._task = None
|
| 323 |
|
| 324 |
|
| 325 |
+
def merge_catalogs(parts: list[Catalog]) -> Catalog:
|
| 326 |
+
"""Join several catalogs into one, each repo keeping its own endpoint and layout.
|
| 327 |
+
|
| 328 |
+
``endpoint`` and ``layout`` are catalog-level defaults, so a repo relying on
|
| 329 |
+
its own catalog's defaults would silently pick up the first catalog's instead
|
| 330 |
+
— a URL list inferring a different manifest, say. Pinning them per repo
|
| 331 |
+
first makes the merge lossless. Duplicates are left for
|
| 332 |
+
:func:`validate_catalog` to reject.
|
| 333 |
+
"""
|
| 334 |
+
if len(parts) == 1:
|
| 335 |
+
return parts[0]
|
| 336 |
+
repos = [
|
| 337 |
+
repo.model_copy(update={"endpoint": repo.endpoint or part.endpoint, "layout": part.layout_for(repo)})
|
| 338 |
+
for part in parts
|
| 339 |
+
for repo in part.repos
|
| 340 |
+
]
|
| 341 |
+
first = parts[0]
|
| 342 |
+
return Catalog(endpoint=first.endpoint, layout=first.layout, repos=repos)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
def validate_catalog(catalog: Catalog) -> None:
|
| 346 |
"""Reject a catalog that would serve nothing, or serve it ambiguously.
|
| 347 |
|
|
|
|
| 352 |
"""
|
| 353 |
if not catalog.repos:
|
| 354 |
raise CatalogError("catalog lists no repos")
|
| 355 |
+
revisions: dict[str, list[str]] = {}
|
| 356 |
for repo in catalog.repos:
|
| 357 |
if "/" not in repo.repo_id:
|
| 358 |
raise CatalogError(f"repo_id must be <owner>/<name>: {repo.repo_id!r}")
|
| 359 |
+
if repo.revision in revisions.setdefault(repo.key, []):
|
| 360 |
+
raise CatalogError(f"duplicate repo in catalog: {repo.repo_id!r} at revision {repo.revision!r}")
|
| 361 |
+
revisions[repo.key].append(repo.revision)
|
| 362 |
if not repo.assets and repo.discover is None:
|
| 363 |
raise CatalogError(
|
| 364 |
f"repo {repo.repo_id!r} lists no assets and no discover block — it would serve nothing"
|
| 365 |
)
|
| 366 |
+
# One repo at several revisions is served side by side, each address carrying
|
| 367 |
+
# its own `/resolve/<sha>/` — which is what tells them apart. A moving ref has
|
| 368 |
+
# no fixed sha to put there, so it can only be served alone.
|
| 369 |
+
for key, revs in revisions.items():
|
| 370 |
+
moving = [r for r in revs if not looks_like_sha(r)]
|
| 371 |
+
if len(revs) > 1 and moving:
|
| 372 |
+
raise CatalogError(
|
| 373 |
+
f"{key.split(':', 1)[1]} appears at {len(revs)} revisions ({', '.join(sorted(revs))}); "
|
| 374 |
+
f"serving several revisions of one repo needs each pinned to a commit sha, "
|
| 375 |
+
f"but {', '.join(repr(r) for r in moving)} is a moving ref"
|
| 376 |
+
)
|
docker/hf-space/hf-storage-api/hf_storage_api/catalog/model.py
CHANGED
|
@@ -16,6 +16,12 @@ from pydantic import BaseModel, Field, model_validator
|
|
| 16 |
|
| 17 |
DEFAULT_HOST = "huggingface.co"
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# Defaults below are written for `catalog_kind="manifest"` (nvidia/simready-assets).
|
| 20 |
# `catalog_kind="wrapp"` (nvidia/srp-staging-controlled) and `catalog_kind=
|
| 21 |
# "package_entry"` (nvidia/SimReady-Internal) each swap in their own defaults for
|
|
@@ -166,8 +172,14 @@ class Catalog(BaseModel):
|
|
| 166 |
return repo.layout or self.layout
|
| 167 |
|
| 168 |
def repo(self, repo_type: str, repo_id: str) -> RepoConfig | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
key = f"{repo_type}:{repo_id}"
|
| 170 |
-
return
|
| 171 |
|
| 172 |
def host(self, repo: RepoConfig) -> str:
|
| 173 |
endpoint = repo.endpoint or self.endpoint
|
|
|
|
| 16 |
|
| 17 |
DEFAULT_HOST = "huggingface.co"
|
| 18 |
|
| 19 |
+
|
| 20 |
+
def looks_like_sha(revision: str) -> bool:
|
| 21 |
+
"""Is this a pinned commit sha rather than a moving ref like ``main``?"""
|
| 22 |
+
return len(revision) == 40 and all(c in "0123456789abcdef" for c in revision.lower())
|
| 23 |
+
|
| 24 |
+
|
| 25 |
# Defaults below are written for `catalog_kind="manifest"` (nvidia/simready-assets).
|
| 26 |
# `catalog_kind="wrapp"` (nvidia/srp-staging-controlled) and `catalog_kind=
|
| 27 |
# "package_entry"` (nvidia/SimReady-Internal) each swap in their own defaults for
|
|
|
|
| 172 |
return repo.layout or self.layout
|
| 173 |
|
| 174 |
def repo(self, repo_type: str, repo_id: str) -> RepoConfig | None:
|
| 175 |
+
"""The first entry for this repo. Use :meth:`repos_for` where a repo may be
|
| 176 |
+
served at several pinned revisions."""
|
| 177 |
+
return next(iter(self.repos_for(repo_type, repo_id)), None)
|
| 178 |
+
|
| 179 |
+
def repos_for(self, repo_type: str, repo_id: str) -> list[RepoConfig]:
|
| 180 |
+
"""Every entry for this repo — one per revision served."""
|
| 181 |
key = f"{repo_type}:{repo_id}"
|
| 182 |
+
return [r for r in self.repos if r.key == key]
|
| 183 |
|
| 184 |
def host(self, repo: RepoConfig) -> str:
|
| 185 |
endpoint = repo.endpoint or self.endpoint
|
docker/hf-space/hf-storage-api/hf_storage_api/catalog/urllist.py
CHANGED
|
@@ -154,18 +154,9 @@ def parse_url_list(text: str, *, layout: LayoutConfig | None = None) -> Catalog:
|
|
| 154 |
if not grouped:
|
| 155 |
raise UrlListError("the URL list is empty")
|
| 156 |
|
| 157 |
-
# One repo at
|
| 158 |
-
#
|
| 159 |
-
#
|
| 160 |
-
by_repo: dict[tuple[str, str, str], list[str]] = {}
|
| 161 |
-
for endpoint, repo_type, repo_id, revision in grouped:
|
| 162 |
-
by_repo.setdefault((endpoint, repo_type, repo_id), []).append(revision)
|
| 163 |
-
for (_, _, repo_id), revisions in by_repo.items():
|
| 164 |
-
if len(revisions) > 1:
|
| 165 |
-
raise UrlListError(
|
| 166 |
-
f"{repo_id} appears at {len(revisions)} revisions ({', '.join(sorted(revisions))}); "
|
| 167 |
-
f"one URI namespace can only serve one revision per repo"
|
| 168 |
-
)
|
| 169 |
|
| 170 |
# The list names the manifest, so it also tells us the layout's marker — but
|
| 171 |
# only adopt it when the list agrees with itself and differs from the default.
|
|
|
|
| 154 |
if not grouped:
|
| 155 |
raise UrlListError("the URL list is empty")
|
| 156 |
|
| 157 |
+
# One repo at several revisions becomes one RepoConfig per revision. Pinned
|
| 158 |
+
# shas are served side by side, told apart by the `/resolve/<sha>/` in their
|
| 159 |
+
# addresses; `validate_catalog` refuses the mix when one of them is a moving ref.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
# The list names the manifest, so it also tells us the layout's marker — but
|
| 162 |
# only adopt it when the list agrees with itself and differs from the default.
|
docker/hf-space/hf-storage-api/hf_storage_api/catalog_sync/publish.py
CHANGED
|
@@ -57,7 +57,7 @@ def review(
|
|
| 57 |
Parsing is :func:`hf_storage_api.catalog.loader.parse_text` — the server's,
|
| 58 |
not a copy — so this rejects exactly what the server would reject at load
|
| 59 |
time: a truncated body, a URL pointing at a USD instead of a manifest, one
|
| 60 |
-
repo
|
| 61 |
"""
|
| 62 |
if not _has_content(text):
|
| 63 |
# Caught before parsing, purely for the error message. An emptied URL list
|
|
@@ -103,7 +103,8 @@ def publish(
|
|
| 103 |
"""Write ``text`` to ``target`` so that no reader can ever see it half-written."""
|
| 104 |
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
| 105 |
assets = sum(len(repo.assets) for repo in catalog.repos)
|
| 106 |
-
|
|
|
|
| 107 |
previous = read_meta(target)
|
| 108 |
|
| 109 |
if previous.get("digest") == digest and target.exists():
|
|
|
|
| 57 |
Parsing is :func:`hf_storage_api.catalog.loader.parse_text` — the server's,
|
| 58 |
not a copy — so this rejects exactly what the server would reject at load
|
| 59 |
time: a truncated body, a URL pointing at a USD instead of a manifest, one
|
| 60 |
+
repo at two revisions when one of them is a moving ref.
|
| 61 |
"""
|
| 62 |
if not _has_content(text):
|
| 63 |
# Caught before parsing, purely for the error message. An emptied URL list
|
|
|
|
| 103 |
"""Write ``text`` to ``target`` so that no reader can ever see it half-written."""
|
| 104 |
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
| 105 |
assets = sum(len(repo.assets) for repo in catalog.repos)
|
| 106 |
+
# One entry per repo, not per revision served.
|
| 107 |
+
repo_ids = tuple(dict.fromkeys(repo.repo_id for repo in catalog.repos))
|
| 108 |
previous = read_meta(target)
|
| 109 |
|
| 110 |
if previous.get("digest") == digest and target.exists():
|
docker/hf-space/hf-storage-api/hf_storage_api/hf/client.py
CHANGED
|
@@ -15,6 +15,7 @@ import logging
|
|
| 15 |
import re
|
| 16 |
from collections.abc import AsyncIterator, Sequence
|
| 17 |
from typing import Any
|
|
|
|
| 18 |
|
| 19 |
import httpx
|
| 20 |
|
|
@@ -28,6 +29,18 @@ logger = logging.getLogger(__name__)
|
|
| 28 |
_API_SEGMENT = {"dataset": "datasets", "model": "models", "space": "spaces"}
|
| 29 |
_URL_SEGMENT = {"dataset": "datasets", "model": "", "space": "spaces"}
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
| 32 |
|
| 33 |
# HF does not send `Retry-After` — it sends `RateLimit` in the
|
|
@@ -106,7 +119,7 @@ class HFClient:
|
|
| 106 |
|
| 107 |
def resolve_url(self, repo_type: str, repo_id: str, revision: str, path: str) -> str:
|
| 108 |
segment = _URL_SEGMENT.get(repo_type, "datasets")
|
| 109 |
-
parts = [self._endpoint, segment, repo_id, "resolve", revision, path]
|
| 110 |
return "/".join([p for p in parts if p])
|
| 111 |
|
| 112 |
def _headers(self, token: str | None) -> dict[str, str]:
|
|
@@ -177,7 +190,7 @@ class HFClient:
|
|
| 177 |
token: str | None = None,
|
| 178 |
) -> list[TreeEntry]:
|
| 179 |
"""List a subtree. Follows the Hub's link-header pagination."""
|
| 180 |
-
url = self.api_url(repo_type, repo_id, "tree", revision, path)
|
| 181 |
params: dict[str, Any] = {}
|
| 182 |
if recursive:
|
| 183 |
params["recursive"] = "true"
|
|
|
|
| 15 |
import re
|
| 16 |
from collections.abc import AsyncIterator, Sequence
|
| 17 |
from typing import Any
|
| 18 |
+
from urllib.parse import quote
|
| 19 |
|
| 20 |
import httpx
|
| 21 |
|
|
|
|
| 29 |
_API_SEGMENT = {"dataset": "datasets", "model": "models", "space": "spaces"}
|
| 30 |
_URL_SEGMENT = {"dataset": "datasets", "model": "", "space": "spaces"}
|
| 31 |
|
| 32 |
+
|
| 33 |
+
def _quote_path(path: str) -> str:
|
| 34 |
+
"""Percent-encode a repo path for a Hub URL.
|
| 35 |
+
|
| 36 |
+
The Hub decodes the URL path, so a raw `%40` in a file name resolves to `@`
|
| 37 |
+
and 404s. Every SimReady package carries one
|
| 38 |
+
(`.metadata/com.nvidia.simready.conformance.Package%401.0.0.json`), and `#`,
|
| 39 |
+
`?` or a space would break the URL outright.
|
| 40 |
+
"""
|
| 41 |
+
return quote(path, safe="/")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
| 45 |
|
| 46 |
# HF does not send `Retry-After` — it sends `RateLimit` in the
|
|
|
|
| 119 |
|
| 120 |
def resolve_url(self, repo_type: str, repo_id: str, revision: str, path: str) -> str:
|
| 121 |
segment = _URL_SEGMENT.get(repo_type, "datasets")
|
| 122 |
+
parts = [self._endpoint, segment, repo_id, "resolve", revision, _quote_path(path)]
|
| 123 |
return "/".join([p for p in parts if p])
|
| 124 |
|
| 125 |
def _headers(self, token: str | None) -> dict[str, str]:
|
|
|
|
| 190 |
token: str | None = None,
|
| 191 |
) -> list[TreeEntry]:
|
| 192 |
"""List a subtree. Follows the Hub's link-header pagination."""
|
| 193 |
+
url = self.api_url(repo_type, repo_id, "tree", revision, _quote_path(path))
|
| 194 |
params: dict[str, Any] = {}
|
| 195 |
if recursive:
|
| 196 |
params["recursive"] = "true"
|
docker/hf-space/hf-storage-api/hf_storage_api/services/capabilities.py
CHANGED
|
@@ -55,14 +55,15 @@ class CapabilitiesService:
|
|
| 55 |
async def ListRoutes(self, request, context):
|
| 56 |
"""Wildcard patterns for the addresses this server serves.
|
| 57 |
|
| 58 |
-
Unused by USD Search's Python client, but
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
| 62 |
"""
|
| 63 |
return capabilities_pb2.ListRoutesResponse(
|
| 64 |
items=[
|
| 65 |
-
capabilities_pb2.Route(wildcard_pattern=f"{address}*")
|
| 66 |
for address in self._store.top_level_addresses()
|
| 67 |
]
|
| 68 |
)
|
|
|
|
| 55 |
async def ListRoutes(self, request, context):
|
| 56 |
"""Wildcard patterns for the addresses this server serves.
|
| 57 |
|
| 58 |
+
Unused by USD Search's Python client, but ``omni.client`` routes by
|
| 59 |
+
these, and it exists only in v1alpha — see plan §2.4. ``**``, not ``*``:
|
| 60 |
+
ovrtx's bundled ``omni.client`` (2.74.0) matches ``*`` within one path
|
| 61 |
+
segment, so a ``*`` route sends no nested address to this provider and
|
| 62 |
+
every asset fails with ``ERROR_NOT_SUPPORTED`` before any RPC is made.
|
| 63 |
"""
|
| 64 |
return capabilities_pb2.ListRoutesResponse(
|
| 65 |
items=[
|
| 66 |
+
capabilities_pb2.Route(wildcard_pattern=f"{address}**")
|
| 67 |
for address in self._store.top_level_addresses()
|
| 68 |
]
|
| 69 |
)
|
docker/hf-space/hf-storage-api/hf_storage_api/services/filefolder.py
CHANGED
|
@@ -37,21 +37,22 @@ class FileFolderService:
|
|
| 37 |
@handle_hf_errors_stream
|
| 38 |
async def ListStat(self, request, context):
|
| 39 |
uri = request.folder.uri
|
| 40 |
-
|
| 41 |
|
| 42 |
-
if
|
| 43 |
# Repo root: the crawl surface, flat. Returning no subfolder_addresses
|
| 44 |
# stops the client's breadth-first walk here, which is what keeps the
|
| 45 |
# indexed set to one entry per asset.
|
| 46 |
semaphore = asyncio.Semaphore(self._settings.enumerate_concurrency)
|
| 47 |
|
| 48 |
-
async def resolve(asset_name: str):
|
| 49 |
async with semaphore:
|
| 50 |
-
return await self._store.folder(repo, asset_name)
|
| 51 |
|
| 52 |
entries: list[filefolder_service_pb2.ListItem] = []
|
| 53 |
-
|
| 54 |
-
|
|
|
|
| 55 |
if loaded is None:
|
| 56 |
continue
|
| 57 |
entry = loaded.entry(loaded.root_usd)
|
|
|
|
| 37 |
@handle_hf_errors_stream
|
| 38 |
async def ListStat(self, request, context):
|
| 39 |
uri = request.folder.uri
|
| 40 |
+
repos = self._store.repos_for_root(uri)
|
| 41 |
|
| 42 |
+
if repos:
|
| 43 |
# Repo root: the crawl surface, flat. Returning no subfolder_addresses
|
| 44 |
# stops the client's breadth-first walk here, which is what keeps the
|
| 45 |
# indexed set to one entry per asset.
|
| 46 |
semaphore = asyncio.Semaphore(self._settings.enumerate_concurrency)
|
| 47 |
|
| 48 |
+
async def resolve(repo, asset_name: str):
|
| 49 |
async with semaphore:
|
| 50 |
+
return repo, await self._store.folder(repo, asset_name)
|
| 51 |
|
| 52 |
entries: list[filefolder_service_pb2.ListItem] = []
|
| 53 |
+
pending = [resolve(repo, name) for repo in repos for name in repo.assets]
|
| 54 |
+
for completed in asyncio.as_completed(pending):
|
| 55 |
+
repo, loaded = await completed
|
| 56 |
if loaded is None:
|
| 57 |
continue
|
| 58 |
entry = loaded.entry(loaded.root_usd)
|
docker/hf-space/hf-storage-api/hf_storage_api/services/fileobject.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
|
| 4 |
-
"""``FileObjectService`` — ``Stat``, ``Enumerate``, ``ReadFromAddress``.
|
| 5 |
|
| 6 |
Two surfaces (plan §6.2):
|
| 7 |
|
|
@@ -94,25 +94,27 @@ class FileObjectService:
|
|
| 94 |
to come out of this one call (plan §2.4).
|
| 95 |
"""
|
| 96 |
address = request.resource_address
|
| 97 |
-
|
| 98 |
-
if
|
| 99 |
# Scoped to something other than a served root. Prefer NOT_FOUND:
|
| 100 |
# any other status aborts the crawl (contract doc §3.4).
|
| 101 |
await context.abort(grpc.StatusCode.NOT_FOUND, address)
|
| 102 |
|
| 103 |
semaphore = asyncio.Semaphore(self._settings.enumerate_concurrency)
|
| 104 |
|
| 105 |
-
async def resolve(asset_name: str):
|
| 106 |
async with semaphore:
|
| 107 |
-
return asset_name, await self._store.folder(repo, asset_name)
|
| 108 |
|
| 109 |
batch: list[fileobject_pb2.AddressInfo] = []
|
| 110 |
# Stream as results arrive rather than resolving everything first: a
|
| 111 |
# crawl that emits nothing for minutes is indistinguishable from a hang.
|
| 112 |
-
|
|
|
|
|
|
|
| 113 |
try:
|
| 114 |
for completed in asyncio.as_completed(tasks):
|
| 115 |
-
asset_name, loaded = await completed
|
| 116 |
if loaded is None:
|
| 117 |
logger.warning("catalogued asset %s is absent upstream; skipping", asset_name)
|
| 118 |
continue
|
|
@@ -156,7 +158,30 @@ class FileObjectService:
|
|
| 156 |
yield fileobject_service_pb2.ReadFromAddressResponse(
|
| 157 |
resource_info=_resource_info(self._store, entry, address)
|
| 158 |
)
|
|
|
|
|
|
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
revision = await self._store.revision(resolved.repo)
|
| 161 |
stream = self._store.hf.stream(
|
| 162 |
resolved.repo.repo_type,
|
|
@@ -168,7 +193,7 @@ class FileObjectService:
|
|
| 168 |
)
|
| 169 |
try:
|
| 170 |
async for chunk in stream:
|
| 171 |
-
yield
|
| 172 |
except asyncio.CancelledError:
|
| 173 |
# The client hung up: close the upstream response instead of draining
|
| 174 |
# it into a dead RPC. With multi-hundred-MB assets that is a real
|
|
@@ -176,13 +201,6 @@ class FileObjectService:
|
|
| 176 |
await stream.aclose()
|
| 177 |
raise
|
| 178 |
|
| 179 |
-
async def Read(self, request, context):
|
| 180 |
-
# The client downloads by address, not by ResourceIdentity, so this is
|
| 181 |
-
# unused. Implement only if the Phase-1.4 Kit log shows it (plan §2.4).
|
| 182 |
-
await context.abort(
|
| 183 |
-
grpc.StatusCode.UNIMPLEMENTED, "Read by identity is not supported; use ReadFromAddress"
|
| 184 |
-
)
|
| 185 |
-
|
| 186 |
# -- write path: read-only service (plan §6.8) ----------------------------
|
| 187 |
|
| 188 |
async def FetchWriteTypeInfo(self, request, context):
|
|
|
|
| 1 |
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
|
| 4 |
+
"""``FileObjectService`` — ``Stat``, ``Enumerate``, ``ReadFromAddress``, ``Read``.
|
| 5 |
|
| 6 |
Two surfaces (plan §6.2):
|
| 7 |
|
|
|
|
| 94 |
to come out of this one call (plan §2.4).
|
| 95 |
"""
|
| 96 |
address = request.resource_address
|
| 97 |
+
repos = self._store.repos_for_root(address)
|
| 98 |
+
if not repos:
|
| 99 |
# Scoped to something other than a served root. Prefer NOT_FOUND:
|
| 100 |
# any other status aborts the crawl (contract doc §3.4).
|
| 101 |
await context.abort(grpc.StatusCode.NOT_FOUND, address)
|
| 102 |
|
| 103 |
semaphore = asyncio.Semaphore(self._settings.enumerate_concurrency)
|
| 104 |
|
| 105 |
+
async def resolve(repo, asset_name: str):
|
| 106 |
async with semaphore:
|
| 107 |
+
return repo, asset_name, await self._store.folder(repo, asset_name)
|
| 108 |
|
| 109 |
batch: list[fileobject_pb2.AddressInfo] = []
|
| 110 |
# Stream as results arrive rather than resolving everything first: a
|
| 111 |
# crawl that emits nothing for minutes is indistinguishable from a hang.
|
| 112 |
+
# Every revision served under this root: each asset's address carries its
|
| 113 |
+
# own `/resolve/<sha>/`, so they cannot collide.
|
| 114 |
+
tasks = [asyncio.create_task(resolve(repo, name)) for repo in repos for name in repo.assets]
|
| 115 |
try:
|
| 116 |
for completed in asyncio.as_completed(tasks):
|
| 117 |
+
repo, asset_name, loaded = await completed
|
| 118 |
if loaded is None:
|
| 119 |
logger.warning("catalogued asset %s is absent upstream; skipping", asset_name)
|
| 120 |
continue
|
|
|
|
| 158 |
yield fileobject_service_pb2.ReadFromAddressResponse(
|
| 159 |
resource_info=_resource_info(self._store, entry, address)
|
| 160 |
)
|
| 161 |
+
async for chunk in self._chunks(resolved):
|
| 162 |
+
yield fileobject_service_pb2.ReadFromAddressResponse(chunk=fileobject_pb2.Chunk(chunk=chunk))
|
| 163 |
|
| 164 |
+
@handle_hf_errors_stream
|
| 165 |
+
async def Read(self, request, context):
|
| 166 |
+
"""Read by ``ResourceIdentity``, as ovrtx's bundled ``omni.client``
|
| 167 |
+
(2.74.0) does after the ``Stat`` that minted the identity; Kit and USD
|
| 168 |
+
Search's client use ``ReadFromAddress`` instead.
|
| 169 |
+
|
| 170 |
+
The identity is the content oid, so it is mapped back to the address it
|
| 171 |
+
was minted for (contract doc §5.1) and re-authorised like any address.
|
| 172 |
+
"""
|
| 173 |
+
identity = request.resource_identity.encoded_identity
|
| 174 |
+
address = self._store.address_for_identity(identity)
|
| 175 |
+
found = await self._store.stat(address) if address else None
|
| 176 |
+
if found is None:
|
| 177 |
+
await context.abort(grpc.StatusCode.NOT_FOUND, identity)
|
| 178 |
+
resolved, entry = found
|
| 179 |
+
|
| 180 |
+
yield fileobject_service_pb2.ReadResponse(metadata=_metadata(entry))
|
| 181 |
+
async for chunk in self._chunks(resolved):
|
| 182 |
+
yield fileobject_service_pb2.ReadResponse(chunk=fileobject_pb2.Chunk(chunk=chunk))
|
| 183 |
+
|
| 184 |
+
async def _chunks(self, resolved):
|
| 185 |
revision = await self._store.revision(resolved.repo)
|
| 186 |
stream = self._store.hf.stream(
|
| 187 |
resolved.repo.repo_type,
|
|
|
|
| 193 |
)
|
| 194 |
try:
|
| 195 |
async for chunk in stream:
|
| 196 |
+
yield chunk
|
| 197 |
except asyncio.CancelledError:
|
| 198 |
# The client hung up: close the upstream response instead of draining
|
| 199 |
# it into a dead RPC. With multi-hundred-MB assets that is a real
|
|
|
|
| 201 |
await stream.aclose()
|
| 202 |
raise
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
# -- write path: read-only service (plan §6.8) ----------------------------
|
| 205 |
|
| 206 |
async def FetchWriteTypeInfo(self, request, context):
|
docker/hf-space/hf-storage-api/hf_storage_api/services/metadata_service.py
CHANGED
|
@@ -6,6 +6,7 @@
|
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
import logging
|
|
|
|
| 9 |
|
| 10 |
import grpc
|
| 11 |
from nvidia.omniverse.storage.metadata.v1alpha import metadata_pb2
|
|
@@ -39,9 +40,14 @@ class MetadataService:
|
|
| 39 |
# Under `catalog_kind="wrapp"` the per-asset filenames use the short stem
|
| 40 |
# from the root USD (e.g. `sm_IV_pole_a01_01`), not the full folder path
|
| 41 |
# that `asset_name` is under a versioned layout (plan §6.3).
|
| 42 |
-
stem = loaded.root_usd.rsplit("/", 1)[-1]
|
| 43 |
manifest_path = f"{asset_name}/{layout.render(layout.manifest, asset_name, stem=stem)}"
|
| 44 |
metadata_path = f"{asset_name}/{layout.render(layout.metadata, asset_name, stem=stem)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
manifest = await self._sidecar(resolved, manifest_path, loaded)
|
| 47 |
asset_metadata = await self._sidecar(resolved, metadata_path, loaded)
|
|
|
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
import logging
|
| 9 |
+
import posixpath
|
| 10 |
|
| 11 |
import grpc
|
| 12 |
from nvidia.omniverse.storage.metadata.v1alpha import metadata_pb2
|
|
|
|
| 40 |
# Under `catalog_kind="wrapp"` the per-asset filenames use the short stem
|
| 41 |
# from the root USD (e.g. `sm_IV_pole_a01_01`), not the full folder path
|
| 42 |
# that `asset_name` is under a versioned layout (plan §6.3).
|
| 43 |
+
stem = posixpath.splitext(loaded.root_usd.rsplit("/", 1)[-1])[0]
|
| 44 |
manifest_path = f"{asset_name}/{layout.render(layout.manifest, asset_name, stem=stem)}"
|
| 45 |
metadata_path = f"{asset_name}/{layout.render(layout.metadata, asset_name, stem=stem)}"
|
| 46 |
+
if layout.catalog_kind == "manifest" and metadata_path not in loaded.files:
|
| 47 |
+
# Same drift as the root USD's (store.py:_data_dir_usd): the
|
| 48 |
+
# descriptive metadata sits next to the root USD as `<stem>.json`,
|
| 49 |
+
# not at the `{data_dir}/{asset_name}.json` the convention names.
|
| 50 |
+
metadata_path = f"{posixpath.splitext(loaded.root_usd)[0]}.json"
|
| 51 |
|
| 52 |
manifest = await self._sidecar(resolved, manifest_path, loaded)
|
| 53 |
asset_metadata = await self._sidecar(resolved, metadata_path, loaded)
|
docker/hf-space/hf-storage-api/hf_storage_api/settings.py
CHANGED
|
@@ -41,6 +41,11 @@ class Settings(BaseSettings):
|
|
| 41 |
catalog_path: Path | None = None
|
| 42 |
catalog_url: str | None = None
|
| 43 |
catalog_refresh_seconds: float = 900.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
# A URL list has nowhere to put a layout, so a non-default one comes from the
|
| 46 |
# environment instead. `manifest` is inferred from the list itself.
|
|
@@ -104,6 +109,10 @@ class Settings(BaseSettings):
|
|
| 104 |
raise ValueError("chunk_size must stay well below the client's 4 MiB default limit")
|
| 105 |
return self
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
def layout_defaults(self):
|
| 108 |
"""Layout for formats that cannot carry one (plan §6.3)."""
|
| 109 |
from .catalog.model import LayoutConfig
|
|
|
|
| 41 |
catalog_path: Path | None = None
|
| 42 |
catalog_url: str | None = None
|
| 43 |
catalog_refresh_seconds: float = 900.0
|
| 44 |
+
# More catalog files, merged with the one above — e.g. a URL list the
|
| 45 |
+
# catalog-sync sidecar writes, next to a YAML catalog from a ConfigMap. Each is
|
| 46 |
+
# polled and guarded on its own. Comma-separated rather than `list[Path]`:
|
| 47 |
+
# pydantic-settings decodes complex types from env as JSON.
|
| 48 |
+
catalog_extra_paths: str = ""
|
| 49 |
|
| 50 |
# A URL list has nowhere to put a layout, so a non-default one comes from the
|
| 51 |
# environment instead. `manifest` is inferred from the list itself.
|
|
|
|
| 109 |
raise ValueError("chunk_size must stay well below the client's 4 MiB default limit")
|
| 110 |
return self
|
| 111 |
|
| 112 |
+
@property
|
| 113 |
+
def extra_catalog_paths(self) -> list[Path]:
|
| 114 |
+
return [Path(item.strip()) for item in self.catalog_extra_paths.split(",") if item.strip()]
|
| 115 |
+
|
| 116 |
def layout_defaults(self):
|
| 117 |
"""Layout for formats that cannot carry one (plan §6.3)."""
|
| 118 |
from .catalog.model import LayoutConfig
|
docker/hf-space/hf-storage-api/hf_storage_api/store.py
CHANGED
|
@@ -24,6 +24,7 @@ from dataclasses import dataclass
|
|
| 24 |
from . import addressing
|
| 25 |
from .cache import MISSING, Missing, SingleFlightCache
|
| 26 |
from .catalog.model import Catalog, LayoutConfig, RepoConfig
|
|
|
|
| 27 |
from .hf.client import HFClient, HFNotFound
|
| 28 |
from .hf.models import TreeEntry
|
| 29 |
from .settings import Settings
|
|
@@ -135,7 +136,9 @@ class AssetStore:
|
|
| 135 |
return None
|
| 136 |
|
| 137 |
def top_level_addresses(self) -> list[str]:
|
| 138 |
-
|
|
|
|
|
|
|
| 139 |
addressing.build(
|
| 140 |
host=self._catalog.host(repo),
|
| 141 |
repo_type=repo.repo_type,
|
|
@@ -144,14 +147,22 @@ class AssetStore:
|
|
| 144 |
scheme=self._settings.uri_scheme,
|
| 145 |
)
|
| 146 |
for repo in self._catalog.repos
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
parsed = self._parse(address)
|
| 151 |
-
if parsed is None:
|
| 152 |
-
return
|
| 153 |
-
|
| 154 |
-
return repo if repo and self._host_matches(parsed, repo) else None
|
| 155 |
|
| 156 |
def resolve(self, address: str) -> Resolved | None:
|
| 157 |
"""Map an address to its catalogued asset folder, using the catalog only.
|
|
@@ -164,26 +175,31 @@ class AssetStore:
|
|
| 164 |
parsed = self._parse(address)
|
| 165 |
if parsed is None:
|
| 166 |
return None
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
)
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
def is_repo_root(self, address: str) -> bool:
|
| 189 |
"""Is this the root of a served repo, with or without a trailing slash?"""
|
|
@@ -212,6 +228,14 @@ class AssetStore:
|
|
| 212 |
revision=self._emit_revision(repo, revision),
|
| 213 |
)
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
def _host_matches(self, parsed, repo: RepoConfig) -> bool:
|
| 216 |
"""The address must name the host this repo is configured against.
|
| 217 |
|
|
@@ -364,7 +388,11 @@ class AssetStore:
|
|
| 364 |
|
| 365 |
``catalog_kind="manifest"``: ``root_usds.json`` where it exists, else the
|
| 366 |
naming convention (plan §3.3 — the index file was absent on 2 of 4
|
| 367 |
-
sampled assets).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
if layout.catalog_kind == "wrapp":
|
| 369 |
manifest_path = f"{asset_name}/{layout.output_manifest}"
|
| 370 |
payload = (
|
|
@@ -428,7 +456,10 @@ class AssetStore:
|
|
| 428 |
# entries[] are relative to the asset folder.
|
| 429 |
return f"{asset_name}/{str(entries[0]).lstrip('/')}"
|
| 430 |
logger.warning("%s has an empty entries[]; falling back to the naming convention", index_path)
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
async def _sidecar_json(self, repo: RepoConfig, revision: str, path: str) -> dict | None:
|
| 434 |
"""Fetch and parse a small JSON sidecar. Immutable within a revision."""
|
|
@@ -528,10 +559,6 @@ class AssetStore:
|
|
| 528 |
}
|
| 529 |
|
| 530 |
|
| 531 |
-
def _looks_like_sha(revision: str) -> bool:
|
| 532 |
-
return len(revision) == 40 and all(c in "0123456789abcdef" for c in revision.lower())
|
| 533 |
-
|
| 534 |
-
|
| 535 |
def _top_level_usd(asset_name: str, files: Mapping[str, TreeEntry]) -> str | None:
|
| 536 |
"""The one USD file (``.usd``/``.usda``/``.usdc``/``.usdz``) sitting directly
|
| 537 |
at the asset folder's root, if there is exactly one candidate set to choose
|
|
@@ -547,6 +574,30 @@ def _top_level_usd(asset_name: str, files: Mapping[str, TreeEntry]) -> str | Non
|
|
| 547 |
return top_level_usds[0] if top_level_usds else None
|
| 548 |
|
| 549 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
def _wrapp_folder_basename(asset_name: str) -> str:
|
| 551 |
"""The ``.wrapp`` file's own name embeds the asset folder's basename, not
|
| 552 |
the full ``asset_name`` — which under ``catalog_kind: wrapp`` is
|
|
|
|
| 24 |
from . import addressing
|
| 25 |
from .cache import MISSING, Missing, SingleFlightCache
|
| 26 |
from .catalog.model import Catalog, LayoutConfig, RepoConfig
|
| 27 |
+
from .catalog.model import looks_like_sha as _looks_like_sha
|
| 28 |
from .hf.client import HFClient, HFNotFound
|
| 29 |
from .hf.models import TreeEntry
|
| 30 |
from .settings import Settings
|
|
|
|
| 136 |
return None
|
| 137 |
|
| 138 |
def top_level_addresses(self) -> list[str]:
|
| 139 |
+
"""One root per repo, however many revisions of it are served: the root
|
| 140 |
+
carries no revision, and its listing spans all of them."""
|
| 141 |
+
addresses = (
|
| 142 |
addressing.build(
|
| 143 |
host=self._catalog.host(repo),
|
| 144 |
repo_type=repo.repo_type,
|
|
|
|
| 147 |
scheme=self._settings.uri_scheme,
|
| 148 |
)
|
| 149 |
for repo in self._catalog.repos
|
| 150 |
+
)
|
| 151 |
+
return list(dict.fromkeys(addresses))
|
| 152 |
+
|
| 153 |
+
def repos_for_root(self, address: str) -> list[RepoConfig]:
|
| 154 |
+
"""Every revision served under this repo root; empty if it is not one.
|
| 155 |
|
| 156 |
+
An address *inside* the repo is not its root: ``ListStat`` and
|
| 157 |
+
``Enumerate`` answer a root with the flat crawl surface, so matching a
|
| 158 |
+
nested folder here would hand ovrtx's client the crawl surface for an
|
| 159 |
+
asset's own folder — it stats a file by listing its parent, finds the
|
| 160 |
+
file missing, and fails every load.
|
| 161 |
+
"""
|
| 162 |
parsed = self._parse(address)
|
| 163 |
+
if parsed is None or parsed.path != "":
|
| 164 |
+
return []
|
| 165 |
+
return self._repos_named(parsed)
|
|
|
|
| 166 |
|
| 167 |
def resolve(self, address: str) -> Resolved | None:
|
| 168 |
"""Map an address to its catalogued asset folder, using the catalog only.
|
|
|
|
| 175 |
parsed = self._parse(address)
|
| 176 |
if parsed is None:
|
| 177 |
return None
|
| 178 |
+
matches: list[Resolved] = []
|
| 179 |
+
for repo in self._repos_named(parsed):
|
| 180 |
+
if parsed.revision and _looks_like_sha(repo.revision) and parsed.revision != repo.revision:
|
| 181 |
+
# Pinned repo: a wrong revision is answerable from the catalog
|
| 182 |
+
# alone, so it costs no upstream request. With several revisions
|
| 183 |
+
# served, this is also what picks the right one.
|
| 184 |
+
continue
|
| 185 |
+
asset_name = next((a for a in repo.assets if addressing.is_within(parsed.path, a)), None)
|
| 186 |
+
if asset_name is not None:
|
| 187 |
+
matches.append(
|
| 188 |
+
Resolved(
|
| 189 |
+
repo=repo,
|
| 190 |
+
layout=self._catalog.layout_for(repo),
|
| 191 |
+
asset_name=asset_name,
|
| 192 |
+
path=parsed.path,
|
| 193 |
+
is_folder=parsed.is_folder,
|
| 194 |
+
address_revision=parsed.revision,
|
| 195 |
+
)
|
| 196 |
)
|
| 197 |
+
if len(matches) > 1:
|
| 198 |
+
# A revision-less address for a folder served at several revisions
|
| 199 |
+
# names no one of them; guessing would serve whichever came first.
|
| 200 |
+
logger.debug("%s matches %d revisions; it must name one", address, len(matches))
|
| 201 |
+
return None
|
| 202 |
+
return matches[0] if matches else None
|
| 203 |
|
| 204 |
def is_repo_root(self, address: str) -> bool:
|
| 205 |
"""Is this the root of a served repo, with or without a trailing slash?"""
|
|
|
|
| 228 |
revision=self._emit_revision(repo, revision),
|
| 229 |
)
|
| 230 |
|
| 231 |
+
def _repos_named(self, parsed) -> list[RepoConfig]:
|
| 232 |
+
"""Every revision served for the repo an address names, at any depth."""
|
| 233 |
+
return [
|
| 234 |
+
repo
|
| 235 |
+
for repo in self._catalog.repos_for(parsed.repo_type, parsed.repo_id)
|
| 236 |
+
if self._host_matches(parsed, repo)
|
| 237 |
+
]
|
| 238 |
+
|
| 239 |
def _host_matches(self, parsed, repo: RepoConfig) -> bool:
|
| 240 |
"""The address must name the host this repo is configured against.
|
| 241 |
|
|
|
|
| 388 |
|
| 389 |
``catalog_kind="manifest"``: ``root_usds.json`` where it exists, else the
|
| 390 |
naming convention (plan §3.3 — the index file was absent on 2 of 4
|
| 391 |
+
sampled assets). When the convention's file is not in the folder either,
|
| 392 |
+
the USD sitting directly in ``data_dir`` — verified 2026-10-08: both
|
| 393 |
+
nvidia/simready-assets@2464ca32 and nvidia/srp-staging-controlled ship
|
| 394 |
+
``simready_usd/sm_<asset>_01.usd`` with no index on most assets, and the
|
| 395 |
+
convention's ``simready_usd/<asset>.usd`` on none."""
|
| 396 |
if layout.catalog_kind == "wrapp":
|
| 397 |
manifest_path = f"{asset_name}/{layout.output_manifest}"
|
| 398 |
payload = (
|
|
|
|
| 456 |
# entries[] are relative to the asset folder.
|
| 457 |
return f"{asset_name}/{str(entries[0]).lstrip('/')}"
|
| 458 |
logger.warning("%s has an empty entries[]; falling back to the naming convention", index_path)
|
| 459 |
+
convention = f"{asset_name}/{layout.render(layout.content_fallback, asset_name)}"
|
| 460 |
+
if convention in files:
|
| 461 |
+
return convention
|
| 462 |
+
return _data_dir_usd(asset_name, layout, files) or convention
|
| 463 |
|
| 464 |
async def _sidecar_json(self, repo: RepoConfig, revision: str, path: str) -> dict | None:
|
| 465 |
"""Fetch and parse a small JSON sidecar. Immutable within a revision."""
|
|
|
|
| 559 |
}
|
| 560 |
|
| 561 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
def _top_level_usd(asset_name: str, files: Mapping[str, TreeEntry]) -> str | None:
|
| 563 |
"""The one USD file (``.usd``/``.usda``/``.usdc``/``.usdz``) sitting directly
|
| 564 |
at the asset folder's root, if there is exactly one candidate set to choose
|
|
|
|
| 574 |
return top_level_usds[0] if top_level_usds else None
|
| 575 |
|
| 576 |
|
| 577 |
+
def _data_dir_usd(asset_name: str, layout: LayoutConfig, files: Mapping[str, TreeEntry]) -> str | None:
|
| 578 |
+
"""The USD file directly inside the asset's ``data_dir``, for ``manifest``
|
| 579 |
+
assets that carry neither a root-USD index nor the conventional
|
| 580 |
+
``<asset>.usd``.
|
| 581 |
+
|
| 582 |
+
Several candidates happen — a variant, a payload — so the SimReady naming
|
| 583 |
+
``sm_<asset>_NN.*`` wins over the others, then alphabetical order, which puts
|
| 584 |
+
``_01`` first.
|
| 585 |
+
"""
|
| 586 |
+
data_dir = layout.render("{data_dir}", asset_name).strip("/")
|
| 587 |
+
folder = f"{asset_name}/{data_dir}" if data_dir else asset_name
|
| 588 |
+
candidates = sorted(
|
| 589 |
+
path
|
| 590 |
+
for path in files
|
| 591 |
+
if path.startswith(f"{folder}/")
|
| 592 |
+
and "/" not in path[len(folder) + 1 :]
|
| 593 |
+
and path.endswith((".usd", ".usda", ".usdc", ".usdz"))
|
| 594 |
+
)
|
| 595 |
+
if not candidates:
|
| 596 |
+
return None
|
| 597 |
+
preferred = f"{folder}/sm_{asset_name.rsplit('/', 1)[-1]}_"
|
| 598 |
+
return next((path for path in candidates if path.startswith(preferred)), candidates[0])
|
| 599 |
+
|
| 600 |
+
|
| 601 |
def _wrapp_folder_basename(asset_name: str) -> str:
|
| 602 |
"""The ``.wrapp`` file's own name embeds the asset folder's basename, not
|
| 603 |
the full ``asset_name`` — which under ``catalog_kind: wrapp`` is
|
docker/hf-space/nginx.conf.template
CHANGED
|
@@ -73,11 +73,10 @@ server {
|
|
| 73 |
proxy_pass http://127.0.0.1:8000;
|
| 74 |
}
|
| 75 |
|
| 76 |
-
# SimReady Central's validator (supervisord's deeptag-validate, 127.0.0.1:8020) answers here in
|
| 77 |
-
# place of deepsearch-api's: the same judge plus each hit's path and caption and a verdict cache.
|
| 78 |
location /vlm_validate/search_result {
|
| 79 |
if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
|
| 80 |
-
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
location /llm_parse/ {
|
|
@@ -86,11 +85,14 @@ server {
|
|
| 86 |
proxy_pass http://127.0.0.1:8000;
|
| 87 |
}
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
location /download {
|
| 90 |
if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
# Bundling an asset + all its dependencies can take longer than a single thumbnail.
|
| 94 |
proxy_read_timeout 300s;
|
| 95 |
}
|
| 96 |
|
|
@@ -263,12 +265,13 @@ server {
|
|
| 263 |
try_files /category_hierarchy.json @slim_not_found;
|
| 264 |
}
|
| 265 |
# One request per result page (QA 6881558: Hugging Face limits each user to ~2,000 requests a minute,
|
| 266 |
-
# and a page of per-image checks took 50):
|
| 267 |
-
#
|
| 268 |
-
#
|
| 269 |
location = /vlm_validate/batch_stream {
|
| 270 |
if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
|
| 271 |
-
|
|
|
|
| 272 |
proxy_buffering off;
|
| 273 |
proxy_read_timeout 120s;
|
| 274 |
gzip off;
|
|
|
|
| 73 |
proxy_pass http://127.0.0.1:8000;
|
| 74 |
}
|
| 75 |
|
|
|
|
|
|
|
| 76 |
location /vlm_validate/search_result {
|
| 77 |
if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
|
| 78 |
+
rewrite ^/vlm_validate/search_result(.*)$ /v3/deepsearch/vlm_validate/search_result$1 break;
|
| 79 |
+
proxy_pass http://127.0.0.1:8000;
|
| 80 |
}
|
| 81 |
|
| 82 |
location /llm_parse/ {
|
|
|
|
| 85 |
proxy_pass http://127.0.0.1:8000;
|
| 86 |
}
|
| 87 |
|
| 88 |
+
# package-download (127.0.0.1:8020, pipeline/package_download/), not deepsearch-api: it answers
|
| 89 |
+
# /download/asset with the asset's whole SimReady package folder, same response contract, and
|
| 90 |
+
# forwards scope=dependencies (and anything outside a catalogued package) to deepsearch-api's
|
| 91 |
+
# own /v3/download/asset. It serves the /download/... paths itself, so no rewrite.
|
| 92 |
location /download {
|
| 93 |
if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
|
| 94 |
+
proxy_pass http://127.0.0.1:8020;
|
| 95 |
+
# Fetching a whole package (or a forwarded dependency bundle) can take longer than a single thumbnail.
|
|
|
|
| 96 |
proxy_read_timeout 300s;
|
| 97 |
}
|
| 98 |
|
|
|
|
| 265 |
try_files /category_hierarchy.json @slim_not_found;
|
| 266 |
}
|
| 267 |
# One request per result page (QA 6881558: Hugging Face limits each user to ~2,000 requests a minute,
|
| 268 |
+
# and a page of per-image checks took 50): deepsearch-api's /vlm_validate/search_result_batch streams one
|
| 269 |
+
# NDJSON verdict per hit. The Explorer calls this path; the request and the lines are the same.
|
| 270 |
+
# Unbuffered so verdicts land as they come; heartbeats keep the stream open.
|
| 271 |
location = /vlm_validate/batch_stream {
|
| 272 |
if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
|
| 273 |
+
rewrite ^ /v3/deepsearch/vlm_validate/search_result_batch break;
|
| 274 |
+
proxy_pass http://127.0.0.1:8000;
|
| 275 |
proxy_buffering off;
|
| 276 |
proxy_read_timeout 120s;
|
| 277 |
gzip off;
|
docker/hf-space/siglip2-model/SOURCE.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
# Generated by scripts/extract_siglip2_model.sh — do not hand-edit.
|
| 2 |
-
source_repo=/tmp/usdsearch-
|
| 3 |
-
source_commit=
|
| 4 |
-
extracted_at=2026-10-
|
|
|
|
| 1 |
# Generated by scripts/extract_siglip2_model.sh — do not hand-edit.
|
| 2 |
+
source_repo=/tmp/claude-1000/-home-arozantsev-Documents-projects-DeepSearch-simready-central-indexing-internal/4b40209a-9038-43e7-b70a-ed77e9f3ffdf/scratchpad/usdsearch-d94e39e4
|
| 3 |
+
source_commit=d94e39e458b982ec1854ceea6a6e948f995944f5
|
| 4 |
+
extracted_at=2026-10-08T10:11:36Z
|
docker/hf-space/siglip2-model/pyproject.toml
CHANGED
|
@@ -11,4 +11,5 @@ dependencies = [
|
|
| 11 |
"wheel>=0.46.3",
|
| 12 |
"urllib3>=2.8.0",
|
| 13 |
"starlette>=1.3.1",
|
|
|
|
| 14 |
]
|
|
|
|
| 11 |
"wheel>=0.46.3",
|
| 12 |
"urllib3>=2.8.0",
|
| 13 |
"starlette>=1.3.1",
|
| 14 |
+
"fsspec>=2026.9.0",
|
| 15 |
]
|
docker/hf-space/siglip2-model/uv.lock
CHANGED
|
@@ -65,11 +65,11 @@ wheels = [
|
|
| 65 |
|
| 66 |
[[package]]
|
| 67 |
name = "fsspec"
|
| 68 |
-
version = "2026.
|
| 69 |
source = { registry = "https://pypi.org/simple" }
|
| 70 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 71 |
wheels = [
|
| 72 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 73 |
]
|
| 74 |
|
| 75 |
[[package]]
|
|
@@ -462,6 +462,7 @@ name = "siglip2-triton-server"
|
|
| 462 |
version = "0.1.0"
|
| 463 |
source = { virtual = "." }
|
| 464 |
dependencies = [
|
|
|
|
| 465 |
{ name = "numpy" },
|
| 466 |
{ name = "pillow" },
|
| 467 |
{ name = "starlette" },
|
|
@@ -473,6 +474,7 @@ dependencies = [
|
|
| 473 |
|
| 474 |
[package.metadata]
|
| 475 |
requires-dist = [
|
|
|
|
| 476 |
{ name = "numpy" },
|
| 477 |
{ name = "pillow", specifier = ">=12.3.0" },
|
| 478 |
{ name = "starlette", specifier = ">=1.3.1" },
|
|
|
|
| 65 |
|
| 66 |
[[package]]
|
| 67 |
name = "fsspec"
|
| 68 |
+
version = "2026.9.0"
|
| 69 |
source = { registry = "https://pypi.org/simple" }
|
| 70 |
+
sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe", size = 333545, upload-time = "2026-09-18T17:50:42.825Z" }
|
| 71 |
wheels = [
|
| 72 |
+
{ url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f", size = 221738, upload-time = "2026-09-18T17:50:41.341Z" },
|
| 73 |
]
|
| 74 |
|
| 75 |
[[package]]
|
|
|
|
| 462 |
version = "0.1.0"
|
| 463 |
source = { virtual = "." }
|
| 464 |
dependencies = [
|
| 465 |
+
{ name = "fsspec" },
|
| 466 |
{ name = "numpy" },
|
| 467 |
{ name = "pillow" },
|
| 468 |
{ name = "starlette" },
|
|
|
|
| 474 |
|
| 475 |
[package.metadata]
|
| 476 |
requires-dist = [
|
| 477 |
+
{ name = "fsspec", specifier = ">=2026.9.0" },
|
| 478 |
{ name = "numpy" },
|
| 479 |
{ name = "pillow", specifier = ">=12.3.0" },
|
| 480 |
{ name = "starlette", specifier = ">=1.3.1" },
|
docker/hf-space/supervisord-gpu.conf
CHANGED
|
@@ -110,7 +110,22 @@ command=/bin/sh -c '\
|
|
| 110 |
exec sleep infinity; \
|
| 111 |
fi; \
|
| 112 |
exec /app/.venv/bin/python -m hf_storage_api'
|
| 113 |
-
environment=PYTHONPATH="/app/hf-storage-api/src",HF_ADAPTER_GRPC_HOST="127.0.0.1",HF_ADAPTER_GRPC_PORT="50051",HF_ADAPTER_CATALOG_SOURCE="file",HF_ADAPTER_CATALOG_PATH="/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml",HF_ADAPTER_CATALOG_FORMAT="yaml",HF_ADAPTER_HF_ENDPOINT="https://huggingface.co",HF_ADAPTER_URI_SCHEME="hf",HF_ADAPTER_ALLOW_ANONYMOUS="true",HF_ADAPTER_LOG_LEVEL="INFO",HF_ADAPTER_HF_TOKEN="%(ENV_HF_STORAGE_API_TOKEN)s"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
directory=/app
|
| 115 |
user=1000
|
| 116 |
autorestart=true
|
|
@@ -155,25 +170,6 @@ stdout_logfile_maxbytes=0
|
|
| 155 |
stderr_logfile=/dev/stderr
|
| 156 |
stderr_logfile_maxbytes=0
|
| 157 |
|
| 158 |
-
[program:deeptag-validate]
|
| 159 |
-
; SimReady Central's validator (docker/hf-space/deeptag-validate/app.py, vendored from
|
| 160 |
-
; simready-central deeptag-validate/, !582) answers /vlm_validate/search_result in place of
|
| 161 |
-
; deepsearch-api's: the same Yes/No log-prob judge on the same engine, plus each hit's path
|
| 162 |
-
; and caption in words-only checks (JUDGE_ASSET_CONTEXT) and an in-memory verdict cache
|
| 163 |
-
; (VALIDATE_CACHE_SIZE, ~120 MB at 500k entries). /vlm_validate/batch_stream (one request per page)
|
| 164 |
-
; fetches each thumbnail through nginx's /images (SHIM_GATEWAY_BASE_URL). The engine settings are deepsearch-api's
|
| 165 |
-
; USDSEARCH_* variables, read when the shell execs (an unset one never stops supervisord).
|
| 166 |
-
command=/usr/bin/env -u PYTHONPATH /bin/sh -c 'RERANKER_BASE_URL="$USDSEARCH_LLM_BASE_URL" RERANKER_MODEL="$USDSEARCH_VISION_VALIDATION_MODEL" RERANKER_NAME="$USDSEARCH_VISION_VALIDATION_MODEL" RERANKER_API_KEY="$USDSEARCH_LLM_API_KEY" exec /app/.venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8020 --log-level warning'
|
| 167 |
-
environment=JUDGE_ASSET_CONTEXT="path+caption",VALIDATE_CACHE_SIZE="500000",VALIDATE_MAX_EDGE="448",RERANKER_MAX_CONCURRENCY="32",RERANKER_BATCH_CONCURRENCY="16",RERANKER_COVER_MIN="0.2",SHIM_GATEWAY_BASE_URL="http://127.0.0.1:%(ENV_PORT)s"
|
| 168 |
-
directory=/app/deeptag-validate
|
| 169 |
-
user=1000
|
| 170 |
-
autorestart=true
|
| 171 |
-
startsecs=3
|
| 172 |
-
stdout_logfile=/dev/stdout
|
| 173 |
-
stdout_logfile_maxbytes=0
|
| 174 |
-
stderr_logfile=/dev/stderr
|
| 175 |
-
stderr_logfile_maxbytes=0
|
| 176 |
-
|
| 177 |
[program:nginx]
|
| 178 |
command=/bin/sh -c "until curl -sf http://localhost:8000/health >/dev/null 2>&1 && curl -sf http://localhost:8010/docs >/dev/null 2>&1; do sleep 2; done; dl=false; case '%(ENV_DOWNLOAD_ENABLED)s' in [Tt]rue|1) if [ -f /app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml ]; then dl=true; else echo 'nginx: no hf-storage-api catalog, rendering download:false' >&2; fi;; esac; sed -e 's/__PORT__/%(ENV_PORT)s/' -e 's|__CORPUS_NAME__|%(ENV_CORPUS_NAME)s|g' -e 's/__DOWNLOAD_ENABLED__/'$dl'/' -e 's|__SUBMIT_CONTENT_URL__|%(ENV_SUBMIT_CONTENT_URL)s|g' /etc/nginx/hf-space.conf.template > /etc/nginx/conf.d/hf-space.conf && exec nginx -g 'daemon off;'"
|
| 179 |
user=root
|
|
|
|
| 110 |
exec sleep infinity; \
|
| 111 |
fi; \
|
| 112 |
exec /app/.venv/bin/python -m hf_storage_api'
|
| 113 |
+
environment=PYTHONPATH="/app/hf-storage-api/src",HF_ADAPTER_GRPC_HOST="127.0.0.1",HF_ADAPTER_GRPC_PORT="50051",HF_ADAPTER_CATALOG_SOURCE="file",HF_ADAPTER_CATALOG_PATH="/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml",HF_ADAPTER_CATALOG_FORMAT="yaml",HF_ADAPTER_HF_ENDPOINT="https://huggingface.co",HF_ADAPTER_URI_SCHEME="hf",HF_ADAPTER_ALLOW_ANONYMOUS="true",HF_ADAPTER_LOG_LEVEL="INFO",HF_ADAPTER_ADVERTISE_FILEFOLDER="true",HF_ADAPTER_HF_TOKEN="%(ENV_HF_STORAGE_API_TOKEN)s"
|
| 114 |
+
directory=/app
|
| 115 |
+
user=1000
|
| 116 |
+
autorestart=true
|
| 117 |
+
startsecs=5
|
| 118 |
+
stdout_logfile=/dev/stdout
|
| 119 |
+
stdout_logfile_maxbytes=0
|
| 120 |
+
stderr_logfile=/dev/stderr
|
| 121 |
+
stderr_logfile_maxbytes=0
|
| 122 |
+
|
| 123 |
+
[program:package-download]
|
| 124 |
+
; See docker/hf-space/supervisord.conf's own copy of this program for the full explanation (identical here
|
| 125 |
+
; apart from `env -u PYTHONPATH`, like this file's other venv programs).
|
| 126 |
+
command=/usr/bin/env -u PYTHONPATH /bin/sh -c '\
|
| 127 |
+
until [ -f /app/hf-space-pipeline/.build/restore/.download-complete ]; do sleep 2; done; \
|
| 128 |
+
cd /app/hf-space-pipeline && exec /app/.venv/bin/python -m pipeline.package_download'
|
| 129 |
directory=/app
|
| 130 |
user=1000
|
| 131 |
autorestart=true
|
|
|
|
| 170 |
stderr_logfile=/dev/stderr
|
| 171 |
stderr_logfile_maxbytes=0
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
[program:nginx]
|
| 174 |
command=/bin/sh -c "until curl -sf http://localhost:8000/health >/dev/null 2>&1 && curl -sf http://localhost:8010/docs >/dev/null 2>&1; do sleep 2; done; dl=false; case '%(ENV_DOWNLOAD_ENABLED)s' in [Tt]rue|1) if [ -f /app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml ]; then dl=true; else echo 'nginx: no hf-storage-api catalog, rendering download:false' >&2; fi;; esac; sed -e 's/__PORT__/%(ENV_PORT)s/' -e 's|__CORPUS_NAME__|%(ENV_CORPUS_NAME)s|g' -e 's/__DOWNLOAD_ENABLED__/'$dl'/' -e 's|__SUBMIT_CONTENT_URL__|%(ENV_SUBMIT_CONTENT_URL)s|g' /etc/nginx/hf-space.conf.template > /etc/nginx/conf.d/hf-space.conf && exec nginx -g 'daemon off;'"
|
| 175 |
user=root
|
docker/hf-space/supervisord.conf
CHANGED
|
@@ -79,10 +79,15 @@ stderr_logfile_maxbytes=0
|
|
| 79 |
; Read-only Omniverse Storage API gRPC server backed by a HuggingFace
|
| 80 |
; dataset (docker/hf-space/hf-storage-api/, vendored+regenerated by
|
| 81 |
; scripts/extract_hf_storage_api.sh — see that script and
|
| 82 |
-
; docs/hf-space-deployment.md for why). Backs
|
| 83 |
-
;
|
| 84 |
-
;
|
| 85 |
-
;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
; backend process in this container — nginx is the sole externally-proxied
|
| 87 |
; port, and gRPC never needs to cross it (same shape as the GPU variant's
|
| 88 |
; own local siglip2-triton on :8001).
|
|
@@ -108,7 +113,31 @@ command=/bin/sh -c '\
|
|
| 108 |
exec sleep infinity; \
|
| 109 |
fi; \
|
| 110 |
exec /app/.venv/bin/python -m hf_storage_api'
|
| 111 |
-
environment=PYTHONPATH="/app/hf-storage-api/src",HF_ADAPTER_GRPC_HOST="127.0.0.1",HF_ADAPTER_GRPC_PORT="50051",HF_ADAPTER_CATALOG_SOURCE="file",HF_ADAPTER_CATALOG_PATH="/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml",HF_ADAPTER_CATALOG_FORMAT="yaml",HF_ADAPTER_HF_ENDPOINT="https://huggingface.co",HF_ADAPTER_URI_SCHEME="hf",HF_ADAPTER_ALLOW_ANONYMOUS="true",HF_ADAPTER_LOG_LEVEL="INFO",HF_ADAPTER_HF_TOKEN="%(ENV_HF_STORAGE_API_TOKEN)s"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
directory=/app
|
| 113 |
user=1000
|
| 114 |
autorestart=true
|
|
@@ -159,25 +188,6 @@ stdout_logfile_maxbytes=0
|
|
| 159 |
stderr_logfile=/dev/stderr
|
| 160 |
stderr_logfile_maxbytes=0
|
| 161 |
|
| 162 |
-
[program:deeptag-validate]
|
| 163 |
-
; SimReady Central's validator (docker/hf-space/deeptag-validate/app.py, vendored from
|
| 164 |
-
; simready-central deeptag-validate/, !582) answers /vlm_validate/search_result in place of
|
| 165 |
-
; deepsearch-api's: the same Yes/No log-prob judge on the same engine, plus each hit's path
|
| 166 |
-
; and caption in words-only checks (JUDGE_ASSET_CONTEXT) and an in-memory verdict cache
|
| 167 |
-
; (VALIDATE_CACHE_SIZE, ~120 MB at 500k entries). /vlm_validate/batch_stream (one request per page)
|
| 168 |
-
; fetches each thumbnail through nginx's /images (SHIM_GATEWAY_BASE_URL). The engine settings are deepsearch-api's
|
| 169 |
-
; USDSEARCH_* variables, read when the shell execs (an unset one never stops supervisord).
|
| 170 |
-
command=/usr/bin/env -u PYTHONPATH /bin/sh -c 'RERANKER_BASE_URL="$USDSEARCH_LLM_BASE_URL" RERANKER_MODEL="$USDSEARCH_VISION_VALIDATION_MODEL" RERANKER_NAME="$USDSEARCH_VISION_VALIDATION_MODEL" RERANKER_API_KEY="$USDSEARCH_LLM_API_KEY" exec /app/.venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8020 --log-level warning'
|
| 171 |
-
environment=JUDGE_ASSET_CONTEXT="path+caption",VALIDATE_CACHE_SIZE="500000",VALIDATE_MAX_EDGE="448",RERANKER_MAX_CONCURRENCY="32",RERANKER_BATCH_CONCURRENCY="16",RERANKER_COVER_MIN="0.2",SHIM_GATEWAY_BASE_URL="http://127.0.0.1:%(ENV_PORT)s"
|
| 172 |
-
directory=/app/deeptag-validate
|
| 173 |
-
user=1000
|
| 174 |
-
autorestart=true
|
| 175 |
-
startsecs=3
|
| 176 |
-
stdout_logfile=/dev/stdout
|
| 177 |
-
stdout_logfile_maxbytes=0
|
| 178 |
-
stderr_logfile=/dev/stderr
|
| 179 |
-
stderr_logfile_maxbytes=0
|
| 180 |
-
|
| 181 |
[program:nginx]
|
| 182 |
; nginx is the *only* process bound to the externally-proxied port, and
|
| 183 |
; Hugging Face's own Space "ready" detection is just "does something answer
|
|
|
|
| 79 |
; Read-only Omniverse Storage API gRPC server backed by a HuggingFace
|
| 80 |
; dataset (docker/hf-space/hf-storage-api/, vendored+regenerated by
|
| 81 |
; scripts/extract_hf_storage_api.sh — see that script and
|
| 82 |
+
; docs/hf-space-deployment.md for why). Backs package-download's
|
| 83 |
+
; whole-package downloads (below; FileFolder ListStat + ReadFromAddress —
|
| 84 |
+
; hence HF_ADAPTER_ADVERTISE_FILEFOLDER, off by default upstream) and
|
| 85 |
+
; deepsearch-api's own GET /v3/download/asset route (search_utils.
|
| 86 |
+
; storage_client's STORAGE_API_GRPC_ENDPOINT, baked in Dockerfile.hf-space;
|
| 87 |
+
; it ignores FileFolder, STORAGE_API_IGNORE_FILEFOLDER_API=true) for a
|
| 88 |
+
; storage_api-backend corpus. Needs hf-storage-api >= 417d9af for that
|
| 89 |
+
; (sub-folder ListStat and percent-encoded Hub paths, hf-storage-api-service#1
|
| 90 |
+
; — see docs/development.md). Bound to loopback only, like every other
|
| 91 |
; backend process in this container — nginx is the sole externally-proxied
|
| 92 |
; port, and gRPC never needs to cross it (same shape as the GPU variant's
|
| 93 |
; own local siglip2-triton on :8001).
|
|
|
|
| 113 |
exec sleep infinity; \
|
| 114 |
fi; \
|
| 115 |
exec /app/.venv/bin/python -m hf_storage_api'
|
| 116 |
+
environment=PYTHONPATH="/app/hf-storage-api/src",HF_ADAPTER_GRPC_HOST="127.0.0.1",HF_ADAPTER_GRPC_PORT="50051",HF_ADAPTER_CATALOG_SOURCE="file",HF_ADAPTER_CATALOG_PATH="/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml",HF_ADAPTER_CATALOG_FORMAT="yaml",HF_ADAPTER_HF_ENDPOINT="https://huggingface.co",HF_ADAPTER_URI_SCHEME="hf",HF_ADAPTER_ALLOW_ANONYMOUS="true",HF_ADAPTER_LOG_LEVEL="INFO",HF_ADAPTER_ADVERTISE_FILEFOLDER="true",HF_ADAPTER_HF_TOKEN="%(ENV_HF_STORAGE_API_TOKEN)s"
|
| 117 |
+
directory=/app
|
| 118 |
+
user=1000
|
| 119 |
+
autorestart=true
|
| 120 |
+
startsecs=5
|
| 121 |
+
stdout_logfile=/dev/stdout
|
| 122 |
+
stdout_logfile_maxbytes=0
|
| 123 |
+
stderr_logfile=/dev/stderr
|
| 124 |
+
stderr_logfile_maxbytes=0
|
| 125 |
+
|
| 126 |
+
[program:package-download]
|
| 127 |
+
; Whole-package downloads (pipeline/package_download/, docs/plans/hf-space-package-download.md). nginx's
|
| 128 |
+
; /download location proxies here instead of deepsearch-api: GET /download/asset returns the asset's whole
|
| 129 |
+
; SimReady package folder (package marker, .metadata/, sidecar JSONs, thumbnails — everything on HF), read
|
| 130 |
+
; through hf-storage-api above (its FileFolder service is on for this: HF_ADAPTER_ADVERTISE_FILEFOLDER), with
|
| 131 |
+
; usdsearch's response contract. scope=dependencies, and any URL outside a catalogued package, is forwarded
|
| 132 |
+
; unchanged to deepsearch-api's own /v3/download/asset. Loopback only. Holds no HF token.
|
| 133 |
+
;
|
| 134 |
+
; Waits for the seed's restore marker (.download-complete: the whole artifact set, catalog included, is on
|
| 135 |
+
; disk — the catalog file alone can be seen half-written), then loads the same catalog hf-storage-api serves
|
| 136 |
+
; from. Without one (an s3-backend corpus, or a run that has none) it forwards every request, so /download
|
| 137 |
+
; behaves as it did before this program existed.
|
| 138 |
+
command=/bin/sh -c '\
|
| 139 |
+
until [ -f /app/hf-space-pipeline/.build/restore/.download-complete ]; do sleep 2; done; \
|
| 140 |
+
cd /app/hf-space-pipeline && exec /app/.venv/bin/python -m pipeline.package_download'
|
| 141 |
directory=/app
|
| 142 |
user=1000
|
| 143 |
autorestart=true
|
|
|
|
| 188 |
stderr_logfile=/dev/stderr
|
| 189 |
stderr_logfile_maxbytes=0
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
[program:nginx]
|
| 192 |
; nginx is the *only* process bound to the externally-proxied port, and
|
| 193 |
; Hugging Face's own Space "ready" detection is just "does something answer
|
docker/hf-space/wheels/SOURCE.txt
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
# Generated by scripts/extract_usdsearch_wheels.sh — do not hand-edit.
|
| 2 |
-
source_image=
|
| 3 |
-
extracted_at=2026-10-
|
|
|
|
| 1 |
# Generated by scripts/extract_usdsearch_wheels.sh — do not hand-edit.
|
| 2 |
+
source_image=gitlab-master.nvidia.com:5005/omniverse/deeptag/usdsearch/usdsearch:fd61a332
|
| 3 |
+
extracted_at=2026-10-09T14:24:46Z
|
docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl
CHANGED
|
Binary files a/docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl differ
|
|
|
docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl
CHANGED
|
Binary files a/docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl differ
|
|
|
docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl
CHANGED
|
Binary files a/docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl differ
|
|
|
docker/hf-space/wheels/constraints.txt
CHANGED
|
@@ -37,7 +37,7 @@ fastapi-swagger==0.4.48
|
|
| 37 |
filelock==3.29.0
|
| 38 |
fire==0.7.1
|
| 39 |
frozenlist==1.8.0
|
| 40 |
-
fsspec==2026.
|
| 41 |
googleapis-common-protos==1.74.0
|
| 42 |
greenlet==3.4.0
|
| 43 |
grpcio==1.80.0
|
|
@@ -67,7 +67,7 @@ langchain-text-splitters==1.1.2
|
|
| 67 |
langgraph==1.2.11
|
| 68 |
langgraph-checkpoint==4.1.1
|
| 69 |
langgraph-prebuilt==1.1.0
|
| 70 |
-
langgraph-sdk==0.4.
|
| 71 |
langsmith==0.8.18
|
| 72 |
markdown-it-py==4.0.0
|
| 73 |
MarkupSafe==3.0.3
|
|
|
|
| 37 |
filelock==3.29.0
|
| 38 |
fire==0.7.1
|
| 39 |
frozenlist==1.8.0
|
| 40 |
+
fsspec==2026.9.0
|
| 41 |
googleapis-common-protos==1.74.0
|
| 42 |
greenlet==3.4.0
|
| 43 |
grpcio==1.80.0
|
|
|
|
| 67 |
langgraph==1.2.11
|
| 68 |
langgraph-checkpoint==4.1.1
|
| 69 |
langgraph-prebuilt==1.1.0
|
| 70 |
+
langgraph-sdk==0.4.6
|
| 71 |
langsmith==0.8.18
|
| 72 |
markdown-it-py==4.0.0
|
| 73 |
MarkupSafe==3.0.3
|
docker/hf-space/wheels/deepsearch_api-0.1.0-py3-none-any.whl
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:380e2f6e9f261b3aedc41c6f3647017002cf68209fed3577eef9e3157209a272
|
| 3 |
+
size 221113
|
docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl
CHANGED
|
Binary files a/docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl differ
|
|
|
docker/hf-space/wheels/search_utils-0.1.0-py3-none-any.whl
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:35f1cc7cf94f7bf845036cd7332ce2888944eb1c1309532b8863f9e7097c3092
|
| 3 |
+
size 286722
|
docker/hf-space/wheels/siglip2_triton_client-0.6.1-py3-none-any.whl
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:daba44a4ac31b836f233e1e7945ab5aaa8b8a2ebcf2702375166f87248f49fc0
|
| 3 |
+
size 5482694
|
docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl
CHANGED
|
Binary files a/docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl differ
|
|
|
licenses/fsspec-2026.4.0.txt
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
BSD 3-Clause License
|
| 2 |
-
|
| 3 |
-
Copyright (c) 2018, Martin Durant
|
| 4 |
-
All rights reserved.
|
| 5 |
-
|
| 6 |
-
Redistribution and use in source and binary forms, with or without
|
| 7 |
-
modification, are permitted provided that the following conditions are met:
|
| 8 |
-
|
| 9 |
-
* Redistributions of source code must retain the above copyright notice, this
|
| 10 |
-
list of conditions and the following disclaimer.
|
| 11 |
-
|
| 12 |
-
* Redistributions in binary form must reproduce the above copyright notice,
|
| 13 |
-
this list of conditions and the following disclaimer in the documentation
|
| 14 |
-
and/or other materials provided with the distribution.
|
| 15 |
-
|
| 16 |
-
* Neither the name of the copyright holder nor the names of its
|
| 17 |
-
contributors may be used to endorse or promote products derived from
|
| 18 |
-
this software without specific prior written permission.
|
| 19 |
-
|
| 20 |
-
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
| 21 |
-
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
| 22 |
-
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
| 23 |
-
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
| 24 |
-
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
| 25 |
-
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
| 26 |
-
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
| 27 |
-
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
| 28 |
-
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
| 29 |
-
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
licenses/{fsspec-2026.3.0.txt → fsspec-2026.9.0.txt}
RENAMED
|
File without changes
|
licenses/{langgraph-sdk-0.4.2.txt → langgraph-sdk-0.4.6.txt}
RENAMED
|
File without changes
|
licenses/{os-redis-server-5-7.0.15-1ubuntu0.24.04.4.txt → os-redis-server-5-7.0.15-1ubuntu0.24.04.5.txt}
RENAMED
|
File without changes
|
licenses/{os-redis-tools-5-7.0.15-1ubuntu0.24.04.4.txt → os-redis-tools-5-7.0.15-1ubuntu0.24.04.5.txt}
RENAMED
|
File without changes
|
pipeline/bake.py
CHANGED
|
@@ -408,20 +408,31 @@ def run(cfg: Config, push: bool = False) -> int:
|
|
| 408 |
|
| 409 |
def download_smoke(base_url: str, timeout_s: float = 180.0) -> bool:
|
| 410 |
"""A storage_api corpus's download route answers for a real asset: search
|
| 411 |
-
one hit, then ask /download/asset for its manifest (sizes only, no bytes)
|
| 412 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
| 414 |
import time
|
| 415 |
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
deadline = time.monotonic() + timeout_s
|
| 417 |
while time.monotonic() < deadline:
|
| 418 |
try:
|
| 419 |
hits = httpx.get(f"{base_url}/search", params={"query": "box", "limit": 1}, timeout=15.0).json()
|
| 420 |
asset_url = hits[0]["url"] if hits else None
|
| 421 |
if asset_url:
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
if
|
|
|
|
|
|
|
| 425 |
return True
|
| 426 |
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError, AttributeError):
|
| 427 |
pass
|
|
@@ -641,9 +652,9 @@ def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
|
|
| 641 |
download_ok = True
|
| 642 |
if healthy and manifest.get("storage_backend") == "storage_api":
|
| 643 |
download_ok = download_smoke("http://localhost:17860")
|
| 644 |
-
print(f" /download/asset
|
| 645 |
if not download_ok:
|
| 646 |
-
print(" FAIL: hf-space image's download route (hf-storage-api) does not answer")
|
| 647 |
ok = False
|
| 648 |
|
| 649 |
# Returned asset URLs are this run's public huggingface.co links
|
|
|
|
| 408 |
|
| 409 |
def download_smoke(base_url: str, timeout_s: float = 180.0) -> bool:
|
| 410 |
"""A storage_api corpus's download route answers for a real asset: search
|
| 411 |
+
one hit, then ask /download/asset for its manifest (sizes only, no bytes) —
|
| 412 |
+
once as the default whole-package download (package-download, through
|
| 413 |
+
hf-storage-api's folder listing: scope "package", the asset itself among
|
| 414 |
+
the files) and once as scope=dependencies (forwarded to deepsearch-api's
|
| 415 |
+
own asset + dependencies bundle). Polls, because hf-storage-api and
|
| 416 |
+
package-download start only once the seed restored the catalog."""
|
| 417 |
import time
|
| 418 |
import httpx
|
| 419 |
+
|
| 420 |
+
def preview(asset_url: str, **params):
|
| 421 |
+
resp = httpx.get(f"{base_url}/download/asset",
|
| 422 |
+
params={"asset_url": asset_url, "manifest_only": "true", **params}, timeout=60.0)
|
| 423 |
+
return resp.json() if resp.status_code == 200 else None
|
| 424 |
+
|
| 425 |
deadline = time.monotonic() + timeout_s
|
| 426 |
while time.monotonic() < deadline:
|
| 427 |
try:
|
| 428 |
hits = httpx.get(f"{base_url}/search", params={"query": "box", "limit": 1}, timeout=15.0).json()
|
| 429 |
asset_url = hits[0]["url"] if hits else None
|
| 430 |
if asset_url:
|
| 431 |
+
package = preview(asset_url)
|
| 432 |
+
deps = preview(asset_url, scope="dependencies")
|
| 433 |
+
if (package and package.get("scope") == "package" and (package.get("file_count") or 0) > 0
|
| 434 |
+
and asset_url in {f["url"] for f in package["files"]}
|
| 435 |
+
and deps and (deps.get("file_count") or 0) > 0):
|
| 436 |
return True
|
| 437 |
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError, AttributeError):
|
| 438 |
pass
|
|
|
|
| 652 |
download_ok = True
|
| 653 |
if healthy and manifest.get("storage_backend") == "storage_api":
|
| 654 |
download_ok = download_smoke("http://localhost:17860")
|
| 655 |
+
print(f" /download/asset package + dependencies manifests for a real asset: {download_ok}")
|
| 656 |
if not download_ok:
|
| 657 |
+
print(" FAIL: hf-space image's download route (package-download / hf-storage-api) does not answer")
|
| 658 |
ok = False
|
| 659 |
|
| 660 |
# Returned asset URLs are this run's public huggingface.co links
|
pipeline/config.py
CHANGED
|
@@ -51,6 +51,13 @@ HF_SPACE_GPU_HARDWARE_DEFAULT = "t4-medium"
|
|
| 51 |
STORAGE_BACKENDS = ("s3", "storage_api")
|
| 52 |
DEFAULT_STORAGE_BACKEND = "s3"
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 55 |
BUILD_DIR = REPO_ROOT / ".build"
|
| 56 |
DEFAULT_ENV_FILE = REPO_ROOT / "config" / "pipeline.env"
|
|
@@ -386,6 +393,16 @@ class Config:
|
|
| 386 |
"USDSEARCH_VISION_VALIDATION_LOGPROB_TOP_LOGPROBS": "20",
|
| 387 |
"USDSEARCH_VISION_VALIDATION_LOGPROB_MAX_TOKENS": "1",
|
| 388 |
"USDSEARCH_VISION_VALIDATION_MAX_TRIES": "2",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
}
|
| 390 |
|
| 391 |
# ── VLM metadata extraction from thumbnails (indexing-time plugin) ──────
|
|
|
|
| 51 |
STORAGE_BACKENDS = ("s3", "storage_api")
|
| 52 |
DEFAULT_STORAGE_BACKEND = "s3"
|
| 53 |
|
| 54 |
+
# Content roots the validator's path fact reads below (usdsearch logprob_asset_path_roots): a Hugging Face dataset
|
| 55 |
+
# at any revision (hf://huggingface.co/datasets/<org>/<ds>/resolve/<rev>/...) and Isaac content at any release.
|
| 56 |
+
VALIDATION_ASSET_PATH_ROOTS = [
|
| 57 |
+
r"(?i)^(?:[a-z][a-z0-9+.-]*://)?huggingface\.co/datasets/[^/]+/[^/]+/(?:(?:resolve|blob)/[^/]+/)?",
|
| 58 |
+
r"/Isaac/[^/]+/Isaac/",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 62 |
BUILD_DIR = REPO_ROOT / ".build"
|
| 63 |
DEFAULT_ENV_FILE = REPO_ROOT / "config" / "pipeline.env"
|
|
|
|
| 393 |
"USDSEARCH_VISION_VALIDATION_LOGPROB_TOP_LOGPROBS": "20",
|
| 394 |
"USDSEARCH_VISION_VALIDATION_LOGPROB_MAX_TOKENS": "1",
|
| 395 |
"USDSEARCH_VISION_VALIDATION_MAX_TRIES": "2",
|
| 396 |
+
# As SimReady Central's validator judged on the Spaces until usdsearch shipped it: views at 448 px,
|
| 397 |
+
# a text query's judge reads each hit's path (below its dataset or Isaac content root) and vision
|
| 398 |
+
# caption, and an image + words query asks whether the candidate matches both (usdsearch !279).
|
| 399 |
+
"USDSEARCH_VISION_VALIDATION_IMAGE_MAX_PX": "448",
|
| 400 |
+
"USDSEARCH_VISION_VALIDATION_LOGPROB_ASSET_FACTS": json.dumps(["path", "vlm_metadata_caption"]),
|
| 401 |
+
"USDSEARCH_VISION_VALIDATION_LOGPROB_ASSET_PATH_ROOTS": json.dumps(VALIDATION_ASSET_PATH_ROOTS),
|
| 402 |
+
"USDSEARCH_VISION_VALIDATION_LOGPROB_IMAGE_TEXT_QUERY": "true",
|
| 403 |
+
# The Redis verdict cache (on: REDIS_URL is set) holds 250k verdicts (~0.5 GB); the Space's Redis
|
| 404 |
+
# has no maxmemory.
|
| 405 |
+
"USDSEARCH_VISION_VALIDATION_CACHE_MAX_ENTRIES": "250000",
|
| 406 |
}
|
| 407 |
|
| 408 |
# ── VLM metadata extraction from thumbnails (indexing-time plugin) ──────
|
pipeline/package_download/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Whole-package downloads for the HF Space (supervisord program ``package-download``).
|
| 2 |
+
|
| 3 |
+
nginx's ``/download`` location sends ``GET /download/asset?asset_url=…[&manifest_only=true]`` here instead of
|
| 4 |
+
to deepsearch-api. The response keeps usdsearch's download contract (the JSON preview, the ZIP with a
|
| 5 |
+
``manifest.json`` at its root, the ``X-Download-Summary`` header, 404/413/503), but the ZIP holds the asset's whole
|
| 6 |
+
SimReady package folder — the nearest ancestor carrying ``com.nvidia.simready.packaging.json``, i.e. the asset
|
| 7 |
+
folder in hf-storage-api's own catalog — rather than the asset plus its USD dependency graph. So the package
|
| 8 |
+
marker, ``.metadata/*.json``, the ``sm_*.json`` sidecar, validation reports, licenses and thumbnails come along,
|
| 9 |
+
laid out exactly as on Hugging Face, contents at the archive root.
|
| 10 |
+
|
| 11 |
+
Bytes come through the in-container hf-storage-api gRPC server (FileFolderService.ListStat + FileObjectService.
|
| 12 |
+
ReadFromAddress), the same server deepsearch-api's own download already reads through: one HF token, one cache,
|
| 13 |
+
one access boundary (only catalogued folders resolve). This process holds no HF token.
|
| 14 |
+
|
| 15 |
+
``scope=dependencies``, and any URL that is not inside a catalogued package, is forwarded unchanged to
|
| 16 |
+
deepsearch-api's ``/v3/download/asset`` — the old asset + dependencies bundle.
|
| 17 |
+
|
| 18 |
+
Design and decision log: docs/plans/hf-space-package-download.md.
|
| 19 |
+
"""
|
pipeline/package_download/__main__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``python -m pipeline.package_download`` — supervisord's ``package-download`` program."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
import uvicorn
|
| 9 |
+
|
| 10 |
+
from .app import create_app
|
| 11 |
+
from .catalog import Catalog
|
| 12 |
+
from .settings import Settings
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main() -> int:
|
| 16 |
+
logging.basicConfig(
|
| 17 |
+
level=logging.INFO,
|
| 18 |
+
stream=sys.stdout,
|
| 19 |
+
format="%(levelname)s %(asctime)s %(name)s - %(message)s",
|
| 20 |
+
)
|
| 21 |
+
settings = Settings.from_env()
|
| 22 |
+
catalog = None
|
| 23 |
+
if not settings.catalog_path.is_file():
|
| 24 |
+
# An s3-backend corpus (or a run without a catalog): nothing is a package,
|
| 25 |
+
# so every request goes to deepsearch-api — the route behaves as before.
|
| 26 |
+
logging.getLogger("package_download").warning(
|
| 27 |
+
"no hf-storage-api catalog at %s: forwarding every download to deepsearch-api", settings.catalog_path
|
| 28 |
+
)
|
| 29 |
+
catalog = Catalog({})
|
| 30 |
+
uvicorn.run(create_app(settings, catalog=catalog), host=settings.host, port=settings.port, log_config=None)
|
| 31 |
+
return 0
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
if __name__ == "__main__":
|
| 35 |
+
sys.exit(main())
|
pipeline/package_download/app.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP surface: ``GET /download/asset`` (also ``/v3/download/asset``), ``/health``, ``/metrics``.
|
| 2 |
+
|
| 3 |
+
Drop-in for usdsearch's download route (see the package docstring); nginx proxies ``/download`` here.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import contextlib
|
| 10 |
+
import dataclasses
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import math
|
| 14 |
+
import shutil
|
| 15 |
+
import tempfile
|
| 16 |
+
import time
|
| 17 |
+
from typing import Any, Awaitable, Callable, Literal, Optional
|
| 18 |
+
|
| 19 |
+
import httpx
|
| 20 |
+
from fastapi import FastAPI, Query, Request
|
| 21 |
+
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
|
| 22 |
+
from starlette.background import BackgroundTask
|
| 23 |
+
|
| 24 |
+
from . import bundle, telemetry
|
| 25 |
+
from .catalog import Catalog
|
| 26 |
+
from .settings import Settings
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger("package_download")
|
| 29 |
+
|
| 30 |
+
# Response headers worth passing back from a forwarded deepsearch-api download.
|
| 31 |
+
_FORWARD_RESPONSE_HEADERS = (
|
| 32 |
+
"content-type",
|
| 33 |
+
"content-length",
|
| 34 |
+
"content-encoding",
|
| 35 |
+
"content-disposition",
|
| 36 |
+
"x-download-summary",
|
| 37 |
+
"access-control-expose-headers",
|
| 38 |
+
"retry-after",
|
| 39 |
+
)
|
| 40 |
+
_HOP_BY_HOP = {"host", "connection", "keep-alive", "transfer-encoding", "upgrade", "content-length", "te", "trailer"}
|
| 41 |
+
|
| 42 |
+
# SourceError.kind -> (status, client-facing detail). "denied" is the backend's
|
| 43 |
+
# own HF token being refused, not the caller's access: answered as 502, never 403,
|
| 44 |
+
# which the Explorer would show as "Access denied" for a public asset.
|
| 45 |
+
_SOURCE_STATUS = {
|
| 46 |
+
"not_found": (404, "Asset not found"),
|
| 47 |
+
"denied": (502, "The storage backend could not read this package (check HF_STORAGE_API_TOKEN)."),
|
| 48 |
+
"busy": (503, "The storage backend is rate limited; retry shortly."),
|
| 49 |
+
"unavailable": (503, "The storage backend is unavailable; retry shortly."),
|
| 50 |
+
"error": (502, "The storage backend failed."),
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
UrlMapping = tuple # (to_internal(url), to_public_document(doc), to_public(url))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _default_url_mapping() -> UrlMapping:
|
| 58 |
+
"""deepsearch-api's own inbound/outbound URL translation (same USDSEARCH_URL_MAPPING_* env)."""
|
| 59 |
+
try:
|
| 60 |
+
from search_utils.url_mapping import rewrite_response_document, to_internal_url, to_public_url
|
| 61 |
+
except ImportError: # local tests without the usdsearch wheels
|
| 62 |
+
logger.warning("search_utils not installed: asset URLs are not translated")
|
| 63 |
+
return (lambda url: url), (lambda doc: doc), (lambda url: url)
|
| 64 |
+
return to_internal_url, rewrite_response_document, to_public_url
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _public_folder(to_public: Callable[[str], str], address: str) -> str:
|
| 68 |
+
"""Public link for the package folder. rewrite_response_document only knows its
|
| 69 |
+
own URL fields, and the rules map to HF's file viewer (`/blob/<rev>/`); a folder's
|
| 70 |
+
page is `/tree/<rev>/`."""
|
| 71 |
+
public = to_public(address)
|
| 72 |
+
return public.replace("/blob/", "/tree/", 1) if public != address else public
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class _Metrics:
|
| 76 |
+
def __init__(self):
|
| 77 |
+
try:
|
| 78 |
+
from prometheus_client import CollectorRegistry, Counter, Histogram, generate_latest
|
| 79 |
+
except ImportError:
|
| 80 |
+
self.registry = None
|
| 81 |
+
return
|
| 82 |
+
self.registry = CollectorRegistry()
|
| 83 |
+
self._generate = generate_latest
|
| 84 |
+
self.requests = Counter(
|
| 85 |
+
"package_download_requests_total",
|
| 86 |
+
"Download requests by route taken and outcome",
|
| 87 |
+
["route", "mode", "outcome"],
|
| 88 |
+
registry=self.registry,
|
| 89 |
+
)
|
| 90 |
+
self.bytes = Counter("package_download_bytes_total", "Package bytes written into ZIPs", registry=self.registry)
|
| 91 |
+
self.seconds = Histogram(
|
| 92 |
+
"package_download_seconds",
|
| 93 |
+
"Wall clock of package downloads (headers sent)",
|
| 94 |
+
["mode"],
|
| 95 |
+
buckets=(0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300),
|
| 96 |
+
registry=self.registry,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
def observe(self, route: str, mode: str, outcome: str, seconds: Optional[float] = None, nbytes: int = 0):
|
| 100 |
+
if self.registry is None:
|
| 101 |
+
return
|
| 102 |
+
self.requests.labels(route, mode, outcome).inc()
|
| 103 |
+
if route == "package" and seconds is not None:
|
| 104 |
+
self.seconds.labels(mode).observe(seconds)
|
| 105 |
+
if nbytes:
|
| 106 |
+
self.bytes.inc(nbytes)
|
| 107 |
+
|
| 108 |
+
def render(self) -> Response:
|
| 109 |
+
if self.registry is None:
|
| 110 |
+
return Response("prometheus_client not installed\n", media_type="text/plain")
|
| 111 |
+
return Response(self._generate(self.registry), media_type="text/plain; version=0.0.4")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class _CleanupFileResponse(FileResponse):
|
| 115 |
+
"""Serves the ZIP, then removes its temp dir and frees the slot — whatever happens.
|
| 116 |
+
|
| 117 |
+
Not Starlette's ``background=``: that only runs after a successful send, so a
|
| 118 |
+
client hanging up mid-download would leak the archive (usdsearch's
|
| 119 |
+
``_TempDirFileResponse``, same reason).
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
def __init__(self, *args, cleanup_dir: str, on_close: Callable[[], None], **kwargs):
|
| 123 |
+
super().__init__(*args, **kwargs)
|
| 124 |
+
self._cleanup_dir = cleanup_dir
|
| 125 |
+
self._on_close = on_close
|
| 126 |
+
|
| 127 |
+
async def __call__(self, scope, receive, send) -> None:
|
| 128 |
+
try:
|
| 129 |
+
await super().__call__(scope, receive, send)
|
| 130 |
+
finally:
|
| 131 |
+
shutil.rmtree(self._cleanup_dir, ignore_errors=True)
|
| 132 |
+
self._on_close()
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class _HTTPError(Exception):
|
| 136 |
+
def __init__(self, status: int, detail: str, headers: Optional[dict] = None):
|
| 137 |
+
self.status, self.detail, self.headers = status, detail, headers or {}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _source_error(exc: bundle.SourceError) -> _HTTPError:
|
| 141 |
+
status, detail = _SOURCE_STATUS.get(exc.kind, _SOURCE_STATUS["error"])
|
| 142 |
+
if status != 503:
|
| 143 |
+
return _HTTPError(status, detail)
|
| 144 |
+
wait = "30" if exc.retry_after is None else str(math.ceil(exc.retry_after))
|
| 145 |
+
return _HTTPError(status, detail, {"Retry-After": wait})
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class _ClientGone(Exception):
|
| 149 |
+
pass
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
async def _client_gone(request: Request) -> None:
|
| 153 |
+
"""Returns once the client has hung up. uvicorn never cancels a handler itself; it
|
| 154 |
+
only answers ``receive()`` with ``http.disconnect`` (after the request's own body)."""
|
| 155 |
+
while (await request.receive())["type"] != "http.disconnect":
|
| 156 |
+
pass
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
async def _unless_client_gone(request: Request, work: Awaitable[Response]) -> Response:
|
| 160 |
+
"""``work``'s response — unless the client hangs up first (or nginx gives up on it):
|
| 161 |
+
then ``work`` is cancelled and awaited, so every read it started has stopped and its
|
| 162 |
+
slot and temp dir are freed, and _ClientGone is raised."""
|
| 163 |
+
task = asyncio.ensure_future(work)
|
| 164 |
+
gone = asyncio.ensure_future(_client_gone(request))
|
| 165 |
+
try:
|
| 166 |
+
await asyncio.wait({task, gone}, return_when=asyncio.FIRST_COMPLETED)
|
| 167 |
+
finally:
|
| 168 |
+
gone.cancel()
|
| 169 |
+
if not task.done():
|
| 170 |
+
task.cancel()
|
| 171 |
+
await asyncio.wait({task})
|
| 172 |
+
if task.cancelled():
|
| 173 |
+
raise _ClientGone
|
| 174 |
+
return task.result()
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def create_app(
|
| 178 |
+
settings: Optional[Settings] = None,
|
| 179 |
+
*,
|
| 180 |
+
catalog: Optional[Catalog] = None,
|
| 181 |
+
source: Any = None,
|
| 182 |
+
url_mapping: Optional[UrlMapping] = None,
|
| 183 |
+
fallback_transport: Optional[httpx.AsyncBaseTransport] = None,
|
| 184 |
+
) -> FastAPI:
|
| 185 |
+
settings = settings or Settings.from_env()
|
| 186 |
+
to_internal, to_public, to_public_url = url_mapping or _default_url_mapping()
|
| 187 |
+
|
| 188 |
+
def public(doc: dict) -> dict:
|
| 189 |
+
doc = to_public(doc)
|
| 190 |
+
doc["package_root"] = _public_folder(to_public_url, doc["package_root"])
|
| 191 |
+
return doc
|
| 192 |
+
|
| 193 |
+
metrics = _Metrics()
|
| 194 |
+
|
| 195 |
+
@contextlib.asynccontextmanager
|
| 196 |
+
async def lifespan(app: FastAPI):
|
| 197 |
+
nonlocal catalog, source
|
| 198 |
+
if catalog is None:
|
| 199 |
+
catalog = Catalog.load(settings.catalog_path)
|
| 200 |
+
owned_source = source is None
|
| 201 |
+
if owned_source:
|
| 202 |
+
from .source import StorageApiSource
|
| 203 |
+
|
| 204 |
+
source = StorageApiSource(
|
| 205 |
+
settings.storage_api_endpoint,
|
| 206 |
+
list_timeout=settings.list_timeout,
|
| 207 |
+
read_timeout=settings.read_timeout,
|
| 208 |
+
)
|
| 209 |
+
app.state.fallback = httpx.AsyncClient(
|
| 210 |
+
base_url=settings.fallback_url,
|
| 211 |
+
timeout=httpx.Timeout(30.0, read=settings.fallback_timeout),
|
| 212 |
+
transport=fallback_transport,
|
| 213 |
+
)
|
| 214 |
+
logger.info(
|
| 215 |
+
"package-download: %d catalogued package(s), storage API %s, fallback %s",
|
| 216 |
+
len(catalog), settings.storage_api_endpoint, settings.fallback_url,
|
| 217 |
+
)
|
| 218 |
+
try:
|
| 219 |
+
yield
|
| 220 |
+
finally:
|
| 221 |
+
await app.state.fallback.aclose()
|
| 222 |
+
if owned_source:
|
| 223 |
+
await source.close()
|
| 224 |
+
|
| 225 |
+
app = FastAPI(title="package-download", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
|
| 226 |
+
# One slot per ZIP being assembled *and* sent: the archive occupies temp_dir until
|
| 227 |
+
# the client has it (usdsearch's DOWNLOAD_MAX_CONCURRENT, same reasoning).
|
| 228 |
+
app.state.slots = slots = asyncio.Semaphore(settings.max_concurrent) if settings.max_concurrent > 0 else None
|
| 229 |
+
# Reads from hf-storage-api in flight across every request — each one a live HF download.
|
| 230 |
+
app.state.connections = connections = (
|
| 231 |
+
asyncio.Semaphore(settings.hf_max_connections) if settings.hf_max_connections > 0 else None
|
| 232 |
+
)
|
| 233 |
+
# Listings in flight, capped apart from the reads: a listing (every manifest_only preview)
|
| 234 |
+
# never queues behind a ZIP's file reads.
|
| 235 |
+
app.state.listings = listings = (
|
| 236 |
+
asyncio.Semaphore(settings.hf_max_listings) if settings.hf_max_listings > 0 else None
|
| 237 |
+
)
|
| 238 |
+
retry = bundle.RetryPolicy(settings.retry_attempts, settings.retry_base_delay, settings.retry_max_delay)
|
| 239 |
+
# hf-storage-api already retries its HF tree fetch (5 tries, waiting out HF's rate-limit reset).
|
| 240 |
+
list_retry = dataclasses.replace(retry, attempts=min(retry.attempts, 2))
|
| 241 |
+
stored_extensions = settings.stored_extension_set()
|
| 242 |
+
|
| 243 |
+
@app.get("/health")
|
| 244 |
+
async def health():
|
| 245 |
+
return {"status": "ok", "packages": len(catalog) if catalog is not None else 0}
|
| 246 |
+
|
| 247 |
+
@app.get("/metrics")
|
| 248 |
+
async def prometheus():
|
| 249 |
+
return metrics.render()
|
| 250 |
+
|
| 251 |
+
async def forward(request: Request) -> Response:
|
| 252 |
+
"""Hand the request to deepsearch-api's own /v3/download/asset, unchanged."""
|
| 253 |
+
params = [(k, v) for k, v in request.query_params.multi_items() if k != "scope"]
|
| 254 |
+
headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP}
|
| 255 |
+
client: httpx.AsyncClient = request.app.state.fallback
|
| 256 |
+
try:
|
| 257 |
+
upstream = await client.send(
|
| 258 |
+
client.build_request("GET", "/v3/download/asset", params=params, headers=headers), stream=True
|
| 259 |
+
)
|
| 260 |
+
except httpx.HTTPError as exc:
|
| 261 |
+
logger.warning("forwarding to deepsearch-api failed: %s", exc)
|
| 262 |
+
metrics.observe("forward", "zip", "502")
|
| 263 |
+
return JSONResponse({"detail": "The dependency download service is unavailable."}, status_code=502)
|
| 264 |
+
out_headers = {k: v for k, v in upstream.headers.items() if k.lower() in _FORWARD_RESPONSE_HEADERS}
|
| 265 |
+
return StreamingResponse(
|
| 266 |
+
upstream.aiter_raw(),
|
| 267 |
+
status_code=upstream.status_code,
|
| 268 |
+
headers=out_headers,
|
| 269 |
+
background=BackgroundTask(upstream.aclose),
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
async def acquire_slot() -> Callable[[], None]:
|
| 273 |
+
if slots is None:
|
| 274 |
+
return lambda: None
|
| 275 |
+
try:
|
| 276 |
+
await asyncio.wait_for(slots.acquire(), timeout=settings.queue_timeout)
|
| 277 |
+
except (asyncio.TimeoutError, TimeoutError):
|
| 278 |
+
raise _HTTPError(503, "Too many downloads in progress; retry shortly.", {"Retry-After": "30"})
|
| 279 |
+
released = False
|
| 280 |
+
|
| 281 |
+
def release() -> None:
|
| 282 |
+
nonlocal released
|
| 283 |
+
if not released:
|
| 284 |
+
released = True
|
| 285 |
+
slots.release()
|
| 286 |
+
|
| 287 |
+
return release
|
| 288 |
+
|
| 289 |
+
async def list_package(package) -> list:
|
| 290 |
+
async with bundle.limited(listings):
|
| 291 |
+
return await source.list_package(package)
|
| 292 |
+
|
| 293 |
+
async def package_download(asset_url: str, package, manifest_only: bool, stats: dict) -> Response:
|
| 294 |
+
try:
|
| 295 |
+
listed = await bundle.retrying(lambda: list_package(package), list_retry, package.root_address)
|
| 296 |
+
except bundle.SourceError as exc:
|
| 297 |
+
logger.warning("listing %s failed: %s", package.root_address, exc)
|
| 298 |
+
raise _source_error(exc)
|
| 299 |
+
stats["n_listed"] = len(listed)
|
| 300 |
+
if not any(f.path == package.asset_path for f in listed):
|
| 301 |
+
raise _HTTPError(404, f"Asset '{to_public_url(asset_url)}' not found")
|
| 302 |
+
total = sum(f.size for f in listed)
|
| 303 |
+
stats["total_bytes"] = total
|
| 304 |
+
|
| 305 |
+
if manifest_only:
|
| 306 |
+
return JSONResponse(public(bundle.preview(asset_url, package, listed)))
|
| 307 |
+
|
| 308 |
+
if settings.max_bundle_bytes > 0 and total > settings.max_bundle_bytes:
|
| 309 |
+
raise _HTTPError(
|
| 310 |
+
413, f"Download bundle is too large ({total} bytes; limit is {settings.max_bundle_bytes})."
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
release = await acquire_slot()
|
| 314 |
+
tmp_dir = tempfile.mkdtemp(prefix="package-download-", dir=settings.temp_dir or None)
|
| 315 |
+
try:
|
| 316 |
+
zip_path = f"{tmp_dir}/bundle.zip"
|
| 317 |
+
try:
|
| 318 |
+
result = await bundle.build_zip(
|
| 319 |
+
zip_path,
|
| 320 |
+
package,
|
| 321 |
+
listed,
|
| 322 |
+
source.read,
|
| 323 |
+
concurrency=settings.concurrency,
|
| 324 |
+
compression=settings.compression,
|
| 325 |
+
stored_extensions=stored_extensions,
|
| 326 |
+
spool_bytes=settings.spool_bytes,
|
| 327 |
+
max_bytes=settings.max_bundle_bytes,
|
| 328 |
+
spool_dir=tmp_dir,
|
| 329 |
+
read_ahead_bytes=settings.read_ahead_bytes,
|
| 330 |
+
connections=connections,
|
| 331 |
+
retry=retry,
|
| 332 |
+
)
|
| 333 |
+
except bundle.RootMissing as exc:
|
| 334 |
+
raise _HTTPError(404, f"Asset '{to_public_url(asset_url)}' could not be retrieved: {exc}")
|
| 335 |
+
except bundle.BundleTooLarge as exc:
|
| 336 |
+
raise _HTTPError(413, str(exc))
|
| 337 |
+
except bundle.SourceError as exc:
|
| 338 |
+
raise _source_error(exc)
|
| 339 |
+
stats.update(n_files=len(result.files), n_failed=len(result.errors), total_bytes=result.total_bytes,
|
| 340 |
+
fetch_ms=result.fetch_ms)
|
| 341 |
+
doc = public(bundle.manifest(asset_url, package, listed, result))
|
| 342 |
+
await asyncio.get_running_loop().run_in_executor(
|
| 343 |
+
None, bundle.finish_zip, zip_path, bundle.manifest_name(listed, package), doc
|
| 344 |
+
)
|
| 345 |
+
except BaseException:
|
| 346 |
+
# No response will carry these away (includes the client hanging up mid-bundle:
|
| 347 |
+
# _unless_client_gone cancels this).
|
| 348 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 349 |
+
release()
|
| 350 |
+
raise
|
| 351 |
+
|
| 352 |
+
return _CleanupFileResponse(
|
| 353 |
+
zip_path,
|
| 354 |
+
media_type="application/zip",
|
| 355 |
+
filename=f"{package.name}.zip",
|
| 356 |
+
headers={
|
| 357 |
+
"X-Download-Summary": json.dumps(doc["summary"]),
|
| 358 |
+
"Access-Control-Expose-Headers": "X-Download-Summary, Content-Disposition",
|
| 359 |
+
},
|
| 360 |
+
cleanup_dir=tmp_dir,
|
| 361 |
+
on_close=release,
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
async def download_asset(
|
| 365 |
+
request: Request,
|
| 366 |
+
asset_url: str = Query(description="Storage URL of the asset (public or internal form)."),
|
| 367 |
+
manifest_only: bool = Query(False, description="Return the JSON preview instead of the ZIP."),
|
| 368 |
+
scope: Literal["package", "dependencies"] = Query(
|
| 369 |
+
"package",
|
| 370 |
+
description="`package` (default): the asset's whole SimReady package folder. "
|
| 371 |
+
"`dependencies`: the asset + its USD dependency graph (deepsearch-api's own bundle).",
|
| 372 |
+
),
|
| 373 |
+
) -> Response:
|
| 374 |
+
mode = "manifest" if manifest_only else "zip"
|
| 375 |
+
internal_url = to_internal(asset_url)
|
| 376 |
+
package = catalog.package_for(internal_url) if scope == "package" else None
|
| 377 |
+
if package is None:
|
| 378 |
+
metrics.observe("forward", mode, "forwarded")
|
| 379 |
+
return await forward(request)
|
| 380 |
+
|
| 381 |
+
started = time.perf_counter()
|
| 382 |
+
stats: dict = {}
|
| 383 |
+
status, http_status, error_type = "success", 200, None
|
| 384 |
+
try:
|
| 385 |
+
return await _unless_client_gone(request, package_download(internal_url, package, manifest_only, stats))
|
| 386 |
+
except _ClientGone:
|
| 387 |
+
# Nobody is listening: the status is for the logs (nginx's "client closed request").
|
| 388 |
+
status, http_status = "cancelled", 499
|
| 389 |
+
logger.info("client went away; download of %s stopped", package.root_address)
|
| 390 |
+
return Response(status_code=499)
|
| 391 |
+
except _HTTPError as exc:
|
| 392 |
+
status, http_status, error_type = "error", exc.status, "HTTPException"
|
| 393 |
+
return JSONResponse({"detail": exc.detail}, status_code=exc.status, headers=exc.headers)
|
| 394 |
+
except Exception as exc:
|
| 395 |
+
status, http_status, error_type = "error", 500, type(exc).__name__
|
| 396 |
+
logger.exception("package download of %s failed", package.root_address)
|
| 397 |
+
return JSONResponse({"detail": "Internal error while building the package download."}, status_code=500)
|
| 398 |
+
finally:
|
| 399 |
+
elapsed = time.perf_counter() - started
|
| 400 |
+
sent = stats.get("total_bytes", 0) if status == "success" and not manifest_only else 0
|
| 401 |
+
metrics.observe("package", mode, str(http_status), elapsed, sent)
|
| 402 |
+
telemetry.emit(
|
| 403 |
+
asset_url=internal_url,
|
| 404 |
+
manifest_only=manifest_only,
|
| 405 |
+
scope=bundle.SCOPE,
|
| 406 |
+
package=package.name,
|
| 407 |
+
status=status,
|
| 408 |
+
error_type=error_type,
|
| 409 |
+
http_status=http_status,
|
| 410 |
+
processing_time_ms=elapsed * 1000,
|
| 411 |
+
user_agent=request.headers.get("user-agent"),
|
| 412 |
+
**stats,
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
for path in ("/download/asset", "/v3/download/asset"):
|
| 416 |
+
app.add_api_route(path, download_asset, methods=["GET"])
|
| 417 |
+
return app
|
pipeline/package_download/bundle.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Package listing -> usdsearch-contract preview/manifest, and the ZIP itself.
|
| 2 |
+
|
| 3 |
+
Contract (usdsearch ``routers_v3/download.py`` at the Space's wheel commit, and what the Explorer bundle reads —
|
| 4 |
+
docs/plans/hf-space-package-download.md §1.2/§1.4): the preview's ``total_size``/``file_count``, the
|
| 5 |
+
``skipped[]`` list, and the ``X-Download-Summary`` keys. Every summary key is kept; the ``skipped_*`` counters
|
| 6 |
+
are always 0 here — a package is listed, not resolved, so nothing is ever skipped — which keeps the Explorer's
|
| 7 |
+
"some files skipped" toast for real failures (``download_failed``) only. ``scope``, ``package_files``,
|
| 8 |
+
``package_root`` and ``root_path`` are additive.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import contextlib
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
import math
|
| 18 |
+
import os
|
| 19 |
+
import random
|
| 20 |
+
import re
|
| 21 |
+
import tempfile
|
| 22 |
+
import threading
|
| 23 |
+
import time
|
| 24 |
+
import zipfile
|
| 25 |
+
from collections import deque
|
| 26 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from typing import AsyncIterator, Awaitable, Callable, Deque, Dict, List, Optional, Tuple, TypeVar
|
| 29 |
+
|
| 30 |
+
from .catalog import Package
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger("package_download")
|
| 33 |
+
T = TypeVar("T")
|
| 34 |
+
|
| 35 |
+
SCOPE = "package"
|
| 36 |
+
MANIFEST_NAME = "manifest.json"
|
| 37 |
+
# Used instead when a package carries its own root-level manifest.json, so the
|
| 38 |
+
# package itself still unpacks byte-identical.
|
| 39 |
+
MANIFEST_FALLBACK_NAME = "download-manifest.json"
|
| 40 |
+
SKIP_COUNTERS = (
|
| 41 |
+
"skipped_not_found",
|
| 42 |
+
"skipped_access_denied",
|
| 43 |
+
"skipped_no_access_or_missing",
|
| 44 |
+
"skipped_deleted",
|
| 45 |
+
"skipped_resolved_elsewhere",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass(frozen=True)
|
| 50 |
+
class FileEntry:
|
| 51 |
+
path: str # repo-relative, as on Hugging Face (not percent-decoded)
|
| 52 |
+
size: int
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class SourceError(Exception):
|
| 56 |
+
"""A storage-side failure, classified so the HTTP layer needs no gRPC knowledge.
|
| 57 |
+
|
| 58 |
+
kind: "not_found" | "denied" (the backend's HF token was refused) | "busy"
|
| 59 |
+
(rate limited) | "unavailable" | "error". ``retryable``: a transient failure
|
| 60 |
+
worth another try (see `classify`); ``retry_after``: seconds the backend asked
|
| 61 |
+
the caller to wait, when it said.
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
def __init__(self, kind: str, message: str, *, retryable: bool = False, retry_after: Optional[float] = None):
|
| 65 |
+
super().__init__(message)
|
| 66 |
+
self.kind = kind
|
| 67 |
+
self.retryable = retryable
|
| 68 |
+
self.retry_after = retry_after
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# gRPC status code name -> SourceError.kind
|
| 72 |
+
_KINDS = {
|
| 73 |
+
"NOT_FOUND": "not_found",
|
| 74 |
+
"INVALID_ARGUMENT": "not_found",
|
| 75 |
+
# hf-storage-api maps an HF 401/403 (dead or missing token) to PERMISSION_DENIED.
|
| 76 |
+
"PERMISSION_DENIED": "denied",
|
| 77 |
+
"UNAUTHENTICATED": "denied",
|
| 78 |
+
"RESOURCE_EXHAUSTED": "busy",
|
| 79 |
+
"UNAVAILABLE": "unavailable",
|
| 80 |
+
"DEADLINE_EXCEEDED": "unavailable",
|
| 81 |
+
}
|
| 82 |
+
# hf-storage-api (hf/client.py `_check`) words every HF status but 401/403/404 as
|
| 83 |
+
# UNAVAILABLE "<status> from <url>: …", and (errors.status_for) any other failure as
|
| 84 |
+
# INTERNAL "<exception class>: …"; these httpx transport errors are the transient ones.
|
| 85 |
+
_HF_STATUS = re.compile(r"(\d{3}) from ")
|
| 86 |
+
_TRANSPORT_ERRORS = frozenset({
|
| 87 |
+
"ConnectError", "ConnectTimeout", "ReadError", "ReadTimeout", "WriteError", "WriteTimeout",
|
| 88 |
+
"PoolTimeout", "RemoteProtocolError", "NetworkError",
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _seconds(value) -> Optional[float]:
|
| 93 |
+
try:
|
| 94 |
+
seconds = float(value)
|
| 95 |
+
except (TypeError, ValueError):
|
| 96 |
+
return None
|
| 97 |
+
return seconds if math.isfinite(seconds) and seconds >= 0 else None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def classify(code: str, details: str, retry_after: Optional[str] = None) -> SourceError:
|
| 101 |
+
"""A gRPC failure from hf-storage-api (status code name, details, its ``retry-after`` trailer) as a SourceError.
|
| 102 |
+
|
| 103 |
+
Retryable: rate limiting, an HF 429/5xx, hf-storage-api unreachable (UNAVAILABLE with no HF
|
| 104 |
+
status: it is restarting), and a dropped HF connection. Never: DEADLINE_EXCEEDED (the whole-file
|
| 105 |
+
deadline — another try would run past nginx's own 300 s), any other HF 4xx (a per-file failure,
|
| 106 |
+
kind "error"), not found, denied, cancelled. Every retryable failure is kind "busy" or
|
| 107 |
+
"unavailable", so one that outlasts its retries fails the whole bundle with 503 + Retry-After,
|
| 108 |
+
never a 404 root or a ZIP with a hole. The details' wording is hf-storage-api's own — keep
|
| 109 |
+
in step with the vendored copy (tests pin it). It sends no ``retry-after`` today.
|
| 110 |
+
"""
|
| 111 |
+
kind = _KINDS.get(code, "error")
|
| 112 |
+
retryable = kind == "busy"
|
| 113 |
+
if kind == "unavailable" and code != "DEADLINE_EXCEEDED":
|
| 114 |
+
status = _HF_STATUS.match(details)
|
| 115 |
+
http_status = int(status.group(1)) if status else None
|
| 116 |
+
if http_status == 429:
|
| 117 |
+
kind, retryable = "busy", True
|
| 118 |
+
elif http_status is not None and http_status < 500:
|
| 119 |
+
kind = "error"
|
| 120 |
+
else:
|
| 121 |
+
retryable = True
|
| 122 |
+
elif code == "INTERNAL" and details.split(":", 1)[0].strip() in _TRANSPORT_ERRORS:
|
| 123 |
+
kind, retryable = "unavailable", True # HF's connection dropped: as transient as a 5xx
|
| 124 |
+
return SourceError(kind, f"{code}: {details}", retryable=retryable, retry_after=_seconds(retry_after))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@dataclass(frozen=True)
|
| 128 |
+
class RetryPolicy:
|
| 129 |
+
"""Retries of a transient SourceError: ``attempts`` tries in all (1 = none), exponential backoff with
|
| 130 |
+
full jitter (a random wait up to ``base_delay`` × 2^n, at most ``max_delay``). A ``retry_after``
|
| 131 |
+
is waited out as given, unless it is longer than ``max_delay``: then the failure stands, with it."""
|
| 132 |
+
|
| 133 |
+
attempts: int = 1
|
| 134 |
+
base_delay: float = 0.5
|
| 135 |
+
max_delay: float = 10.0
|
| 136 |
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
|
| 137 |
+
rand: Callable[[], float] = random.random
|
| 138 |
+
|
| 139 |
+
def delay(self, exc: SourceError, attempt: int) -> Optional[float]:
|
| 140 |
+
"""Seconds to wait after failed try ``attempt`` (0-based) before the next one, or None to give up."""
|
| 141 |
+
if not exc.retryable or attempt + 1 >= self.attempts:
|
| 142 |
+
return None
|
| 143 |
+
if exc.retry_after is not None:
|
| 144 |
+
return exc.retry_after if exc.retry_after <= self.max_delay else None
|
| 145 |
+
return self.rand() * min(self.max_delay, self.base_delay * 2 ** attempt)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
NO_RETRY = RetryPolicy()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def retrying(call: Callable[[], Awaitable[T]], policy: RetryPolicy, what: str) -> T:
|
| 152 |
+
"""``call()``, tried again per ``policy`` while it fails with a transient SourceError.
|
| 153 |
+
|
| 154 |
+
Only SourceError is caught: a cancellation (the client gone) ends it at once, backoff included.
|
| 155 |
+
"""
|
| 156 |
+
attempt = 0
|
| 157 |
+
while True:
|
| 158 |
+
try:
|
| 159 |
+
return await call()
|
| 160 |
+
except SourceError as exc:
|
| 161 |
+
delay = policy.delay(exc, attempt)
|
| 162 |
+
if delay is None:
|
| 163 |
+
raise
|
| 164 |
+
logger.warning("%s: %s; try %d of %d in %.1f s", what, exc, attempt + 2, policy.attempts, delay)
|
| 165 |
+
await policy.sleep(delay)
|
| 166 |
+
attempt += 1
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def limited(semaphore: Optional[asyncio.Semaphore]):
|
| 170 |
+
"""``async with`` the semaphore, or nothing when there is no limit."""
|
| 171 |
+
return semaphore if semaphore is not None else contextlib.nullcontext()
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class _ByteBudget:
|
| 175 |
+
"""One bundle's read-ahead RAM (``limit`` bytes; 0 = unlimited), granted strictly first come, first served.
|
| 176 |
+
|
| 177 |
+
A request larger than the whole budget is granted once nothing else is held — so it never deadlocks
|
| 178 |
+
the bundle, and is fetched alone — and a small request never overtakes a waiting large one.
|
| 179 |
+
(build_zip never asks for more than the budget; a request of all of it is fetched alone.)
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
def __init__(self, limit: int):
|
| 183 |
+
self.limit = limit
|
| 184 |
+
self.used = 0
|
| 185 |
+
self._waiters: Deque[Tuple[int, asyncio.Future]] = deque()
|
| 186 |
+
|
| 187 |
+
def cost(self, size: int) -> int:
|
| 188 |
+
return min(size, self.limit) if self.limit > 0 else 0
|
| 189 |
+
|
| 190 |
+
def _fits(self, n: int) -> bool:
|
| 191 |
+
return self.limit <= 0 or self.used == 0 or self.used + n <= self.limit
|
| 192 |
+
|
| 193 |
+
async def acquire(self, n: int) -> None:
|
| 194 |
+
if not self._waiters and self._fits(n):
|
| 195 |
+
self.used += n
|
| 196 |
+
return
|
| 197 |
+
waiter = (n, asyncio.get_running_loop().create_future())
|
| 198 |
+
self._waiters.append(waiter)
|
| 199 |
+
try:
|
| 200 |
+
await waiter[1]
|
| 201 |
+
except asyncio.CancelledError:
|
| 202 |
+
if waiter[1].done() and not waiter[1].cancelled():
|
| 203 |
+
self.used -= n # granted just as we were cancelled: hand it back
|
| 204 |
+
elif waiter in self._waiters:
|
| 205 |
+
self._waiters.remove(waiter)
|
| 206 |
+
self._grant()
|
| 207 |
+
raise
|
| 208 |
+
|
| 209 |
+
def release(self, n: int) -> None:
|
| 210 |
+
self.used -= n
|
| 211 |
+
self._grant()
|
| 212 |
+
|
| 213 |
+
def _grant(self) -> None:
|
| 214 |
+
while self._waiters:
|
| 215 |
+
n, future = self._waiters[0]
|
| 216 |
+
if future.done(): # its task was cancelled; it cleans up after itself
|
| 217 |
+
self._waiters.popleft()
|
| 218 |
+
continue
|
| 219 |
+
if not self._fits(n):
|
| 220 |
+
return
|
| 221 |
+
self._waiters.popleft()
|
| 222 |
+
self.used += n
|
| 223 |
+
future.set_result(None)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class BundleTooLarge(Exception):
|
| 227 |
+
pass
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class RootMissing(Exception):
|
| 231 |
+
pass
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def summary(listed: int, downloaded: int, failed: int) -> dict:
|
| 235 |
+
"""The per-bundle summary, in usdsearch's key order plus the additive keys."""
|
| 236 |
+
return {
|
| 237 |
+
# usdsearch counts the root's dependencies; here every other file in the
|
| 238 |
+
# package — the Explorer's preview shows "dependencies" as file_count - 1.
|
| 239 |
+
"dependencies_reported": max(listed - 1, 0),
|
| 240 |
+
"downloaded": downloaded,
|
| 241 |
+
**{key: 0 for key in SKIP_COUNTERS},
|
| 242 |
+
"respelled": 0,
|
| 243 |
+
"download_failed": failed,
|
| 244 |
+
"scope": SCOPE,
|
| 245 |
+
"package_files": listed,
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def manifest_name(files: List[FileEntry], package: Package) -> str:
|
| 250 |
+
names = {package.relative(f.path) for f in files}
|
| 251 |
+
return MANIFEST_FALLBACK_NAME if MANIFEST_NAME in names else MANIFEST_NAME
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def preview(asset_url: str, package: Package, files: List[FileEntry]) -> dict:
|
| 255 |
+
"""The ``manifest_only=true`` response: what the ZIP would hold, no bytes moved."""
|
| 256 |
+
return {
|
| 257 |
+
"root_url": asset_url,
|
| 258 |
+
"file_count": len(files),
|
| 259 |
+
"total_size": sum(f.size for f in files),
|
| 260 |
+
"files": [{"url": package.address(f.path), "size": f.size} for f in files],
|
| 261 |
+
"errors": {},
|
| 262 |
+
"summary": {**summary(len(files), downloaded=len(files), failed=0), "accessible": len(files)},
|
| 263 |
+
"skipped": [],
|
| 264 |
+
"scope": SCOPE,
|
| 265 |
+
"package_root": package.root_address,
|
| 266 |
+
"root_path": package.root_path,
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _compress_type(arc: str, mode: str, stored_extensions: frozenset) -> int:
|
| 271 |
+
if mode == "stored":
|
| 272 |
+
return zipfile.ZIP_STORED
|
| 273 |
+
if mode == "deflate":
|
| 274 |
+
return zipfile.ZIP_DEFLATED
|
| 275 |
+
return zipfile.ZIP_STORED if os.path.splitext(arc)[1].lower() in stored_extensions else zipfile.ZIP_DEFLATED
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def safe_arcname(package: Package, path: str) -> Optional[str]:
|
| 279 |
+
"""In-archive name for a listed file, or None if it would escape the archive root."""
|
| 280 |
+
if not path.startswith(package.folder + "/"):
|
| 281 |
+
return None
|
| 282 |
+
arc = package.relative(path)
|
| 283 |
+
parts = arc.split("/")
|
| 284 |
+
if not arc or arc.startswith("/") or any(p in ("", ".", "..") for p in parts):
|
| 285 |
+
return None
|
| 286 |
+
return arc
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
@dataclass
|
| 290 |
+
class BundleResult:
|
| 291 |
+
files: List[dict] = field(default_factory=list) # {url, path, size} per member written
|
| 292 |
+
errors: Dict[str, str] = field(default_factory=dict) # address -> message
|
| 293 |
+
total_bytes: int = 0
|
| 294 |
+
fetch_ms: float = 0.0
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
Reader = Callable[[Package, str], AsyncIterator[bytes]] # an async generator
|
| 298 |
+
_COPY_CHUNK = 1024 * 1024
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
async def build_zip(
|
| 302 |
+
zip_path: str,
|
| 303 |
+
package: Package,
|
| 304 |
+
files: List[FileEntry],
|
| 305 |
+
read: Reader,
|
| 306 |
+
*,
|
| 307 |
+
concurrency: int,
|
| 308 |
+
compression: str,
|
| 309 |
+
stored_extensions: frozenset,
|
| 310 |
+
spool_bytes: int,
|
| 311 |
+
max_bytes: int = 0,
|
| 312 |
+
spool_dir: Optional[str] = None,
|
| 313 |
+
read_ahead_bytes: int = 0,
|
| 314 |
+
connections: Optional[asyncio.Semaphore] = None,
|
| 315 |
+
retry: RetryPolicy = NO_RETRY,
|
| 316 |
+
) -> BundleResult:
|
| 317 |
+
"""Fetch every listed file (bounded concurrency) and write it into a ZIP at ``zip_path``.
|
| 318 |
+
|
| 319 |
+
Each file is buffered in a SpooledTemporaryFile (RAM up to ``spool_bytes``, capped
|
| 320 |
+
at ``read_ahead_bytes``, then ``spool_dir``; ``spool_bytes`` 0 spills at once), then
|
| 321 |
+
copied into the archive by a single writer thread — ZipFile is not thread-safe, and
|
| 322 |
+
deflate stays off the event loop. A file that fails is recorded in ``errors`` and
|
| 323 |
+
left out; the root asset failing raises RootMissing (usdsearch's 404). Does not
|
| 324 |
+
write the manifest — see `finish_zip`.
|
| 325 |
+
|
| 326 |
+
Each file takes, in this order: the part of its listed size its spool keeps in RAM
|
| 327 |
+
from ``read_ahead_bytes`` (this bundle's RAM for bytes fetched but not yet written;
|
| 328 |
+
0 = unlimited — what spills to disk is not charged, so large files still stream side
|
| 329 |
+
by side), a ``concurrency`` slot, then — only while its bytes stream, never during a
|
| 330 |
+
backoff — a ``connections`` slot (reads in flight across every bundle). A transient
|
| 331 |
+
failure is retried per ``retry`` from byte 0: nothing reaches the archive before a
|
| 332 |
+
file is whole. Cancelled, it stops every read and the writer before it returns.
|
| 333 |
+
"""
|
| 334 |
+
result = BundleResult()
|
| 335 |
+
semaphore = asyncio.Semaphore(max(concurrency, 1))
|
| 336 |
+
budget = _ByteBudget(read_ahead_bytes)
|
| 337 |
+
# A spool's RAM before it spills: never more than the budget, so it holds no more than
|
| 338 |
+
# it is charged, and at least 1 — a SpooledTemporaryFile with max_size 0 never spills.
|
| 339 |
+
in_ram = max(min(spool_bytes, read_ahead_bytes) if read_ahead_bytes > 0 else spool_bytes, 1)
|
| 340 |
+
mode = compression.strip().lower()
|
| 341 |
+
started = time.perf_counter()
|
| 342 |
+
|
| 343 |
+
async def download(entry: FileEntry, spool) -> int:
|
| 344 |
+
async def attempt() -> int:
|
| 345 |
+
spool.seek(0)
|
| 346 |
+
spool.truncate() # a retry starts over: nothing of a failed try is kept
|
| 347 |
+
async with limited(connections):
|
| 348 |
+
async with contextlib.aclosing(read(package, entry.path)) as chunks:
|
| 349 |
+
async for chunk in chunks:
|
| 350 |
+
spool.write(chunk)
|
| 351 |
+
return spool.tell()
|
| 352 |
+
|
| 353 |
+
return await retrying(attempt, retry, package.address(entry.path))
|
| 354 |
+
|
| 355 |
+
async def fetch(entry: FileEntry):
|
| 356 |
+
# (entry, spool, size, exc, cost): `cost` stays reserved until the
|
| 357 |
+
# consumer has written the spool, or dropped it.
|
| 358 |
+
cost = budget.cost(min(entry.size, in_ram))
|
| 359 |
+
await budget.acquire(cost)
|
| 360 |
+
spool = tempfile.SpooledTemporaryFile(max_size=in_ram, dir=spool_dir)
|
| 361 |
+
try:
|
| 362 |
+
async with semaphore:
|
| 363 |
+
size = await download(entry, spool)
|
| 364 |
+
except asyncio.CancelledError:
|
| 365 |
+
spool.close()
|
| 366 |
+
budget.release(cost)
|
| 367 |
+
raise
|
| 368 |
+
except Exception as exc: # noqa: BLE001 — best-effort per file, like usdsearch
|
| 369 |
+
spool.close()
|
| 370 |
+
budget.release(cost)
|
| 371 |
+
return entry, None, 0, exc, 0
|
| 372 |
+
spool.seek(0)
|
| 373 |
+
return entry, spool, size, None, cost
|
| 374 |
+
|
| 375 |
+
tasks = [asyncio.create_task(fetch(f)) for f in files]
|
| 376 |
+
stop = threading.Event() # tells the writer thread to let go of the member it is copying
|
| 377 |
+
try:
|
| 378 |
+
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="package-zip") as writer:
|
| 379 |
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
|
| 380 |
+
for completed in asyncio.as_completed(tasks):
|
| 381 |
+
entry, spool, size, exc, cost = await completed
|
| 382 |
+
address = package.address(entry.path)
|
| 383 |
+
if spool is None:
|
| 384 |
+
if isinstance(exc, SourceError) and exc.kind in ("denied", "busy", "unavailable"):
|
| 385 |
+
raise exc # the backend itself is down; a ZIP of holes helps nobody
|
| 386 |
+
if entry.path == package.asset_path:
|
| 387 |
+
raise RootMissing(str(exc) or "not found")
|
| 388 |
+
result.errors[address] = str(exc) or type(exc).__name__
|
| 389 |
+
continue
|
| 390 |
+
try:
|
| 391 |
+
with spool:
|
| 392 |
+
result.total_bytes += size
|
| 393 |
+
if max_bytes > 0 and result.total_bytes > max_bytes:
|
| 394 |
+
raise BundleTooLarge(f"Download bundle exceeded the size limit of {max_bytes} bytes.")
|
| 395 |
+
arc = safe_arcname(package, entry.path)
|
| 396 |
+
if arc is None:
|
| 397 |
+
result.errors[address] = "path outside the package"
|
| 398 |
+
continue
|
| 399 |
+
info = zipfile.ZipInfo(arc, date_time=time.localtime()[:6])
|
| 400 |
+
info.compress_type = _compress_type(arc, mode, stored_extensions)
|
| 401 |
+
info.external_attr = 0o644 << 16
|
| 402 |
+
await _write_member(writer, stop, zf, info, spool, size)
|
| 403 |
+
result.files.append({"url": address, "path": arc, "size": size})
|
| 404 |
+
finally:
|
| 405 |
+
budget.release(cost)
|
| 406 |
+
finally:
|
| 407 |
+
for task in tasks:
|
| 408 |
+
task.cancel()
|
| 409 |
+
if tasks:
|
| 410 |
+
# Until they have really stopped: a cancelled read ends its RPC (and
|
| 411 |
+
# hf-storage-api its HF request) before the caller frees the slot and temp dir.
|
| 412 |
+
await asyncio.wait(tasks)
|
| 413 |
+
# Close the spools of fetches that finished but were never written (an
|
| 414 |
+
# exception above): a disk-spilled one would otherwise hold a temp file.
|
| 415 |
+
for task in tasks:
|
| 416 |
+
if not task.cancelled() and task.exception() is None:
|
| 417 |
+
_, spool, _, _, cost = task.result()
|
| 418 |
+
if spool is not None and not spool.closed:
|
| 419 |
+
spool.close()
|
| 420 |
+
budget.release(cost)
|
| 421 |
+
result.fetch_ms = (time.perf_counter() - started) * 1000
|
| 422 |
+
return result
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
async def _write_member(writer: ThreadPoolExecutor, stop: threading.Event, zf, info, spool, size: int) -> None:
|
| 426 |
+
"""Copy one spooled file into the archive on the writer thread.
|
| 427 |
+
|
| 428 |
+
Cancelled mid-copy, it stops the thread and waits for it to let go of the member
|
| 429 |
+
and the spool, without blocking the event loop: otherwise ZipFile.close() would
|
| 430 |
+
refuse an open member and the executor's shutdown would wait on the loop.
|
| 431 |
+
"""
|
| 432 |
+
copying = writer.submit(_copy_member, zf, info, spool, size, stop)
|
| 433 |
+
try:
|
| 434 |
+
await asyncio.wrap_future(copying)
|
| 435 |
+
finally:
|
| 436 |
+
if not copying.done():
|
| 437 |
+
stop.set()
|
| 438 |
+
await asyncio.wait({asyncio.wrap_future(copying)})
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _copy_member(zf: zipfile.ZipFile, info: zipfile.ZipInfo, src, size: int, stop: threading.Event) -> None:
|
| 442 |
+
with zf.open(info, "w", force_zip64=size >= zipfile.ZIP64_LIMIT) as dst:
|
| 443 |
+
while not stop.is_set(): # set only when the bundle is abandoned
|
| 444 |
+
chunk = src.read(_COPY_CHUNK)
|
| 445 |
+
if not chunk:
|
| 446 |
+
break
|
| 447 |
+
dst.write(chunk)
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def finish_zip(zip_path: str, name: str, manifest: dict) -> None:
|
| 451 |
+
with zipfile.ZipFile(zip_path, "a") as zf:
|
| 452 |
+
zf.writestr(name, json.dumps(manifest, indent=2))
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def manifest(asset_url: str, package: Package, listed: List[FileEntry], result: BundleResult) -> dict:
|
| 456 |
+
"""The ``manifest.json`` written into the archive (usdsearch's shape + additive keys)."""
|
| 457 |
+
return {
|
| 458 |
+
"root_url": asset_url,
|
| 459 |
+
"file_count": len(result.files),
|
| 460 |
+
"total_size": result.total_bytes,
|
| 461 |
+
"files": sorted(result.files, key=lambda f: f["path"]),
|
| 462 |
+
"summary": summary(len(listed), downloaded=len(result.files), failed=len(result.errors)),
|
| 463 |
+
"skipped": [],
|
| 464 |
+
"errors": result.errors,
|
| 465 |
+
"scope": SCOPE,
|
| 466 |
+
"package_root": package.root_address,
|
| 467 |
+
"root_path": package.root_path,
|
| 468 |
+
}
|
pipeline/package_download/catalog.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``asset_url`` -> the SimReady package folder it belongs to, from hf-storage-api's own catalog.
|
| 2 |
+
|
| 3 |
+
The catalog's ``assets:`` are exactly the package folders: pipeline/hf_catalog.py maps every matched file to the
|
| 4 |
+
nearest ancestor holding the corpus's ``asset_marker`` (``com.nvidia.simready.packaging.json``). Reading the same
|
| 5 |
+
file hf-storage-api serves from means the two can't disagree about what a package is, and an address outside
|
| 6 |
+
every catalogued folder never becomes a package download (hf-storage-api's ``resolve()`` refuses it again
|
| 7 |
+
server-side).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from pathlib import Path, PurePosixPath
|
| 14 |
+
from typing import Dict, FrozenSet, Optional, Tuple
|
| 15 |
+
from urllib.parse import quote
|
| 16 |
+
|
| 17 |
+
import yaml
|
| 18 |
+
|
| 19 |
+
from .. import hf_addressing
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _host(endpoint: str) -> str:
|
| 23 |
+
# hf-storage-api's Catalog.host(): the endpoint minus its scheme.
|
| 24 |
+
return endpoint.split("://", 1)[-1].rstrip("/")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def encode_path(path: str) -> str:
|
| 28 |
+
"""Percent-encode a repo path for an address handed to hf-storage-api.
|
| 29 |
+
|
| 30 |
+
Its ``addressing.parse()`` unquotes every segment, so a file literally named
|
| 31 |
+
``Package%401.0.0.json`` must travel as ``Package%25401.0.0.json`` to arrive
|
| 32 |
+
as itself. A no-op for ordinary names.
|
| 33 |
+
"""
|
| 34 |
+
return quote(path, safe="/")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(frozen=True)
|
| 38 |
+
class Package:
|
| 39 |
+
host: str
|
| 40 |
+
repo_type: str
|
| 41 |
+
repo_id: str
|
| 42 |
+
revision: str # the catalog's configured revision (a pinned sha for simready-assets)
|
| 43 |
+
folder: str # repo-relative package folder, e.g. "appliance_toaster_a01"
|
| 44 |
+
asset_path: str # repo-relative path of the requested asset inside it
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def name(self) -> str:
|
| 48 |
+
return PurePosixPath(self.folder).name
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def root_path(self) -> str:
|
| 52 |
+
"""The requested asset's path inside the archive (package contents sit at its root)."""
|
| 53 |
+
return self.relative(self.asset_path)
|
| 54 |
+
|
| 55 |
+
def relative(self, path: str) -> str:
|
| 56 |
+
return path[len(self.folder) + 1:]
|
| 57 |
+
|
| 58 |
+
def address(self, path: str, *, is_folder: bool = False) -> str:
|
| 59 |
+
"""The hf:// address hf-storage-api resolves for a repo path (encoded, see `encode_path`)."""
|
| 60 |
+
return hf_addressing.build(
|
| 61 |
+
host=self.host,
|
| 62 |
+
repo_type=self.repo_type,
|
| 63 |
+
repo_id=self.repo_id,
|
| 64 |
+
revision=self.revision,
|
| 65 |
+
path=encode_path(path),
|
| 66 |
+
is_folder=is_folder,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def root_address(self) -> str:
|
| 71 |
+
return self.address(self.folder, is_folder=True)
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def repo_prefix(self) -> str:
|
| 75 |
+
"""Every address hf-storage-api emits for this repo starts with this."""
|
| 76 |
+
return hf_addressing.build(
|
| 77 |
+
host=self.host, repo_type=self.repo_type, repo_id=self.repo_id, revision=self.revision, is_folder=True
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class Catalog:
|
| 82 |
+
"""The repos and package folders of one hf-storage-api catalog (YAML)."""
|
| 83 |
+
|
| 84 |
+
def __init__(self, doc: dict):
|
| 85 |
+
endpoint = doc.get("endpoint") or "https://huggingface.co"
|
| 86 |
+
self._repos: Dict[Tuple[str, str, str], Tuple[str, FrozenSet[str]]] = {}
|
| 87 |
+
for repo in doc.get("repos") or []:
|
| 88 |
+
key = (_host(repo.get("endpoint") or endpoint), repo.get("repo_type") or "dataset", repo["repo_id"])
|
| 89 |
+
# "" (a file at the repo root with no marker above it) is never a
|
| 90 |
+
# package: downloading it would mean the whole repo.
|
| 91 |
+
folders = frozenset(f for f in (str(a).strip("/") for a in repo.get("assets") or []) if f)
|
| 92 |
+
self._repos[key] = (str(repo.get("revision") or "main"), folders)
|
| 93 |
+
|
| 94 |
+
@classmethod
|
| 95 |
+
def load(cls, path: Path) -> "Catalog":
|
| 96 |
+
return cls(yaml.safe_load(Path(path).read_text()) or {})
|
| 97 |
+
|
| 98 |
+
def __len__(self) -> int:
|
| 99 |
+
return sum(len(folders) for _, folders in self._repos.values())
|
| 100 |
+
|
| 101 |
+
def package_for(self, address: str) -> Optional[Package]:
|
| 102 |
+
"""The package folder an internal ``hf://`` asset address sits in, or None."""
|
| 103 |
+
try:
|
| 104 |
+
parsed = hf_addressing.parse(address)
|
| 105 |
+
except hf_addressing.AddressError:
|
| 106 |
+
return None
|
| 107 |
+
if parsed.is_folder or not parsed.path:
|
| 108 |
+
return None
|
| 109 |
+
entry = self._repos.get((parsed.host, parsed.repo_type, parsed.repo_id))
|
| 110 |
+
if entry is None:
|
| 111 |
+
return None
|
| 112 |
+
revision, folders = entry
|
| 113 |
+
# A pinned repo only serves its own sha (hf-storage-api's resolve() rule).
|
| 114 |
+
if parsed.revision and hf_addressing.looks_like_sha(revision) and parsed.revision != revision:
|
| 115 |
+
return None
|
| 116 |
+
parts = parsed.path.split("/")[:-1]
|
| 117 |
+
if ".." in parts:
|
| 118 |
+
return None
|
| 119 |
+
# Nearest catalogued ancestor, the same rule hf_catalog used to pick it.
|
| 120 |
+
for depth in range(len(parts), 0, -1):
|
| 121 |
+
folder = "/".join(parts[:depth])
|
| 122 |
+
if folder in folders:
|
| 123 |
+
return Package(
|
| 124 |
+
host=parsed.host,
|
| 125 |
+
repo_type=parsed.repo_type,
|
| 126 |
+
repo_id=parsed.repo_id,
|
| 127 |
+
revision=revision,
|
| 128 |
+
folder=folder,
|
| 129 |
+
asset_path=parsed.path,
|
| 130 |
+
)
|
| 131 |
+
return None
|
pipeline/package_download/settings.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PACKAGE_DOWNLOAD_*`` settings, read once from the environment (baked by the Space Dockerfiles)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from dataclasses import dataclass, fields
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Mapping, Optional
|
| 9 |
+
|
| 10 |
+
PREFIX = "PACKAGE_DOWNLOAD_"
|
| 11 |
+
# Where hf_space_seed restores the run's hf-storage-api catalog — the same file
|
| 12 |
+
# [program:hf-storage-api] serves from (supervisord.conf).
|
| 13 |
+
DEFAULT_CATALOG = "/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml"
|
| 14 |
+
# Same list as usdsearch's DownloadSettings.stored_extensions: already-compressed
|
| 15 |
+
# containers are stored, everything else (.usda, .json, .mdl) is deflated.
|
| 16 |
+
DEFAULT_STORED_EXTENSIONS = ".png,.jpg,.jpeg,.webp,.avif,.jxl,.gif,.mp4,.mov,.zip,.usdz,.gz,.bz2,.xz,.7z,.ktx2,.basis"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class Settings:
|
| 21 |
+
host: str = "127.0.0.1"
|
| 22 |
+
port: int = 8020
|
| 23 |
+
catalog_path: Path = Path(DEFAULT_CATALOG)
|
| 24 |
+
# hf-storage-api, loopback, no TLS (STORAGE_API_GRPC_ENDPOINT/STORAGE_API_SSL=false in the Dockerfiles).
|
| 25 |
+
storage_api_endpoint: str = "127.0.0.1:50051"
|
| 26 |
+
# deepsearch-api, for scope=dependencies and anything outside a catalogued package.
|
| 27 |
+
fallback_url: str = "http://127.0.0.1:8000"
|
| 28 |
+
# Concurrent file reads while assembling one ZIP.
|
| 29 |
+
concurrency: int = 8
|
| 30 |
+
# Concurrent file reads from hf-storage-api — each one a live Hugging Face download — across
|
| 31 |
+
# all requests; 0 = no cap. Keep it plus hf_max_listings under hf-storage-api's
|
| 32 |
+
# HF_ADAPTER_HTTP_MAX_CONNECTIONS (20), which deepsearch-api's scope=dependencies downloads
|
| 33 |
+
# share: a read queued in that pool fails after 30 s.
|
| 34 |
+
hf_max_connections: int = 12
|
| 35 |
+
# Concurrent package listings across all requests, capped apart from the reads so a listing
|
| 36 |
+
# (every manifest_only preview) never waits behind a ZIP's file reads; 0 = no cap. A listing is
|
| 37 |
+
# answered from hf-storage-api's cached tree of the package: at most one HF request at a time.
|
| 38 |
+
hf_max_listings: int = 4
|
| 39 |
+
# RAM of one ZIP's files fetched ahead of its writer (being read or waiting to be written):
|
| 40 |
+
# what their spools hold before spilling to temp_dir, which is not counted; 0 = unlimited.
|
| 41 |
+
# Worst case in all: max_concurrent × this.
|
| 42 |
+
read_ahead_bytes: int = 256 * 1024 * 1024
|
| 43 |
+
# A file read (or the listing) that fails transiently — HF 429/5xx, a dropped connection,
|
| 44 |
+
# hf-storage-api restarting — is retried with exponential backoff and full jitter: up to
|
| 45 |
+
# retry_attempts tries in all (1 = no retry), a random wait under retry_base_delay × 2^n,
|
| 46 |
+
# at most retry_max_delay seconds. A Retry-After longer than that is not waited out: the
|
| 47 |
+
# request fails 503 with it. The listing gets 2 tries at most (hf-storage-api retries it too).
|
| 48 |
+
retry_attempts: int = 4
|
| 49 |
+
retry_base_delay: float = 0.5
|
| 50 |
+
retry_max_delay: float = 10.0
|
| 51 |
+
# ZIPs assembled (and held on disk until sent) at once; further requests queue.
|
| 52 |
+
max_concurrent: int = 4
|
| 53 |
+
# Seconds a request waits for a free slot before 503 + Retry-After.
|
| 54 |
+
queue_timeout: float = 120.0
|
| 55 |
+
# Largest package served, in bytes; 0 = unlimited.
|
| 56 |
+
max_bundle_bytes: int = 0
|
| 57 |
+
# Directory for the temp ZIPs (None = system default). Must be disk-backed.
|
| 58 |
+
temp_dir: Optional[str] = None
|
| 59 |
+
# "auto" (store `stored_extensions`, deflate the rest), "deflate" or "stored".
|
| 60 |
+
compression: str = "auto"
|
| 61 |
+
stored_extensions: str = DEFAULT_STORED_EXTENSIONS
|
| 62 |
+
# A file read is buffered in RAM up to this size (at most read_ahead_bytes), then spills to
|
| 63 |
+
# temp_dir; 0 = straight to disk.
|
| 64 |
+
spool_bytes: int = 16 * 1024 * 1024
|
| 65 |
+
# gRPC deadlines (seconds): one ListStat call, one whole-file ReadFromAddress.
|
| 66 |
+
list_timeout: float = 60.0
|
| 67 |
+
read_timeout: float = 300.0
|
| 68 |
+
# Read timeout for the forwarded deepsearch-api request (it bundles, then answers).
|
| 69 |
+
fallback_timeout: float = 300.0
|
| 70 |
+
|
| 71 |
+
@classmethod
|
| 72 |
+
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings":
|
| 73 |
+
env = os.environ if env is None else env
|
| 74 |
+
values = {}
|
| 75 |
+
for field in fields(cls):
|
| 76 |
+
raw = env.get(PREFIX + field.name.upper())
|
| 77 |
+
if raw is None or raw == "":
|
| 78 |
+
continue
|
| 79 |
+
default = field.default
|
| 80 |
+
if isinstance(default, bool):
|
| 81 |
+
values[field.name] = raw.strip().lower() in ("1", "true", "yes", "on")
|
| 82 |
+
elif isinstance(default, int):
|
| 83 |
+
values[field.name] = int(raw)
|
| 84 |
+
elif isinstance(default, float):
|
| 85 |
+
values[field.name] = float(raw)
|
| 86 |
+
elif isinstance(default, Path):
|
| 87 |
+
values[field.name] = Path(raw)
|
| 88 |
+
else:
|
| 89 |
+
values[field.name] = raw
|
| 90 |
+
# deepsearch-api's own variable, so the two can't point at different servers.
|
| 91 |
+
if "storage_api_endpoint" not in values and env.get("STORAGE_API_GRPC_ENDPOINT"):
|
| 92 |
+
values["storage_api_endpoint"] = env["STORAGE_API_GRPC_ENDPOINT"]
|
| 93 |
+
return cls(**values)
|
| 94 |
+
|
| 95 |
+
def stored_extension_set(self) -> frozenset:
|
| 96 |
+
"""Normalized (lowercase, dot-prefixed) extensions stored uncompressed under "auto"."""
|
| 97 |
+
out = set()
|
| 98 |
+
for raw in self.stored_extensions.split(","):
|
| 99 |
+
ext = raw.strip().lower()
|
| 100 |
+
if ext:
|
| 101 |
+
out.add(ext if ext.startswith(".") else f".{ext}")
|
| 102 |
+
return frozenset(out)
|
pipeline/package_download/source.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Package bytes through the in-container hf-storage-api (Omniverse Storage API over gRPC).
|
| 2 |
+
|
| 3 |
+
``list_package``: a breadth-first ``FileFolderService.ListStat`` walk from the package root — one call per
|
| 4 |
+
sub-folder (~10 for a SimReady package), all answered from hf-storage-api's cached recursive tree of that one
|
| 5 |
+
folder (a single HF ``tree`` request per package per ``HF_ADAPTER_FOLDER_CACHE_TTL``). Needs the server started
|
| 6 |
+
with ``HF_ADAPTER_ADVERTISE_FILEFOLDER=true``, and hf-storage-api >= 417d9af: before it, ListStat on a folder
|
| 7 |
+
inside a repo crawled every catalogued asset (hf-storage-api-service#1).
|
| 8 |
+
|
| 9 |
+
``read``: ``FileObjectService.ReadFromAddress``, consumed chunk by chunk — not search_utils'
|
| 10 |
+
``download_file_content``, which buffers the whole file and stats it first.
|
| 11 |
+
|
| 12 |
+
The stubs are the ones the usdsearch ``storage_api`` wheel installs in the Space venv (the same ones
|
| 13 |
+
deepsearch-api and hf-storage-api itself use there).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import re
|
| 19 |
+
from typing import AsyncIterator, List
|
| 20 |
+
|
| 21 |
+
import grpc
|
| 22 |
+
from nvidia.omniverse.storage.filefolder.v1alpha import filefolder_service_pb2, filefolder_service_pb2_grpc
|
| 23 |
+
from nvidia.omniverse.storage.fileobject.v1alpha import fileobject_service_pb2, fileobject_service_pb2_grpc
|
| 24 |
+
|
| 25 |
+
from .bundle import FileEntry, SourceError, classify
|
| 26 |
+
from .catalog import Package
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# hf-storage-api's addressing.build() escapes exactly these (hf-storage-api-service
|
| 30 |
+
# !6, the fix for its #1): `%` because parse() unquotes, `#`/`?` because urlsplit
|
| 31 |
+
# cuts at them. Undone in one pass — the exact inverse, so it is right for a server
|
| 32 |
+
# with that fix (`Package%25401.0.0.json` -> `Package%401.0.0.json`) and a no-op
|
| 33 |
+
# on one without it, which emits the name raw.
|
| 34 |
+
_ADDRESS_ESCAPE = re.compile(r"%(25|23|3F)")
|
| 35 |
+
_UNESCAPED = {"25": "%", "23": "#", "3F": "?"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _unescape(path: str) -> str:
|
| 39 |
+
return _ADDRESS_ESCAPE.sub(lambda m: _UNESCAPED[m.group(1)], path)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _error(exc: grpc.aio.AioRpcError) -> SourceError:
|
| 43 |
+
# The classification (kind, retryable) is bundle.classify's, which tests can reach without grpc.
|
| 44 |
+
trailers = dict(exc.trailing_metadata() or ())
|
| 45 |
+
return classify(exc.code().name, exc.details() or "", trailers.get("retry-after"))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class StorageApiSource:
|
| 49 |
+
def __init__(self, endpoint: str, *, list_timeout: float = 60.0, read_timeout: float = 300.0):
|
| 50 |
+
# hf-storage-api's chunks stay well under 4 MiB (its own settings check),
|
| 51 |
+
# so the default receive limit is enough.
|
| 52 |
+
self._channel = grpc.aio.insecure_channel(endpoint)
|
| 53 |
+
self._folders = filefolder_service_pb2_grpc.FileFolderServiceStub(self._channel)
|
| 54 |
+
self._objects = fileobject_service_pb2_grpc.FileObjectServiceStub(self._channel)
|
| 55 |
+
self._list_timeout = list_timeout
|
| 56 |
+
self._read_timeout = read_timeout
|
| 57 |
+
|
| 58 |
+
async def close(self) -> None:
|
| 59 |
+
await self._channel.close()
|
| 60 |
+
|
| 61 |
+
@staticmethod
|
| 62 |
+
def _repo_path(address: str, package: Package) -> str:
|
| 63 |
+
"""Repo-relative path of an address hf-storage-api emitted.
|
| 64 |
+
|
| 65 |
+
Sliced off the known repo prefix and unescaped with the exact inverse of
|
| 66 |
+
the server's own escaping, not parsed: a full percent-decode would turn a
|
| 67 |
+
name like ``Package%401.0.0.json`` into a file that doesn't exist.
|
| 68 |
+
"""
|
| 69 |
+
prefix = package.repo_prefix
|
| 70 |
+
if not address.startswith(prefix):
|
| 71 |
+
raise SourceError("error", f"unexpected address from storage backend: {address!r}")
|
| 72 |
+
return _unescape(address[len(prefix):].strip("/"))
|
| 73 |
+
|
| 74 |
+
async def list_package(self, package: Package) -> List[FileEntry]:
|
| 75 |
+
files: List[FileEntry] = []
|
| 76 |
+
pending = [package.folder]
|
| 77 |
+
seen = set()
|
| 78 |
+
while pending:
|
| 79 |
+
folder = pending.pop()
|
| 80 |
+
if folder in seen:
|
| 81 |
+
continue
|
| 82 |
+
seen.add(folder)
|
| 83 |
+
request = filefolder_service_pb2.ListStatRequest(
|
| 84 |
+
folder=filefolder_service_pb2.FolderAddress(uri=package.address(folder, is_folder=True))
|
| 85 |
+
)
|
| 86 |
+
call = self._folders.ListStat(request, timeout=self._list_timeout)
|
| 87 |
+
try:
|
| 88 |
+
async for response in call:
|
| 89 |
+
for sub in response.subfolder_addresses:
|
| 90 |
+
path = self._repo_path(sub.uri, package)
|
| 91 |
+
if path.startswith(package.folder + "/"):
|
| 92 |
+
pending.append(path)
|
| 93 |
+
for item in response.entries:
|
| 94 |
+
path = self._repo_path(item.resource_address, package)
|
| 95 |
+
files.append(FileEntry(path=path, size=int(item.resource_info.metadata.data_object_size)))
|
| 96 |
+
except grpc.aio.AioRpcError as exc:
|
| 97 |
+
raise _error(exc) from None
|
| 98 |
+
finally:
|
| 99 |
+
call.cancel() # see read()
|
| 100 |
+
return sorted(files, key=lambda f: f.path)
|
| 101 |
+
|
| 102 |
+
async def read(self, package: Package, path: str) -> AsyncIterator[bytes]:
|
| 103 |
+
request = fileobject_service_pb2.ReadFromAddressRequest(resource_address=package.address(path))
|
| 104 |
+
call = self._objects.ReadFromAddress(request, timeout=self._read_timeout)
|
| 105 |
+
try:
|
| 106 |
+
async for response in call:
|
| 107 |
+
if response.chunk.chunk:
|
| 108 |
+
yield response.chunk.chunk
|
| 109 |
+
except grpc.aio.AioRpcError as exc:
|
| 110 |
+
raise _error(exc) from None
|
| 111 |
+
finally:
|
| 112 |
+
# A no-op once the call has finished. Left early otherwise (the consumer closed this
|
| 113 |
+
# generator: its spool failed, say), the call would stay open until its deadline,
|
| 114 |
+
# holding an HF download in hf-storage-api's pool. A cancelled task cancels it anyway.
|
| 115 |
+
call.cancel()
|
pipeline/package_download/telemetry.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The ``asset_load_total`` business event, in the shape deepsearch-api writes to stdout.
|
| 2 |
+
|
| 3 |
+
deepsearch-api emits one per ``/download/asset`` call (``telemetry_track_asset_load`` -> ``JSONStdoutSpanExporter``:
|
| 4 |
+
a flat JSON object, every key Elasticsearch-prefixed by value type). The HF Space log forwarder
|
| 5 |
+
(services/hf_space_log_forwarder/parsing.py) ships any stdout line whose JSON carries ``s_telemetry_key``, so a
|
| 6 |
+
package download has to emit the same record or it drops out of the download stream. ``service`` tells the two
|
| 7 |
+
apart; forwarded (``scope=dependencies``/non-package) requests are not recorded here — deepsearch-api records
|
| 8 |
+
those itself.
|
| 9 |
+
|
| 10 |
+
Gated like deepsearch-api's: ``USE_SEARCH_TELEMETRY`` (Dockerfiles bake it true) and ``SEARCH_TELEMETRY_STDOUT``.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
import os
|
| 18 |
+
import time
|
| 19 |
+
from typing import Any, Mapping, Optional
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger("package_download.telemetry")
|
| 22 |
+
|
| 23 |
+
_TRUE = ("1", "true", "yes", "on")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def enabled(env: Optional[Mapping[str, str]] = None) -> bool:
|
| 27 |
+
env = os.environ if env is None else env
|
| 28 |
+
return (
|
| 29 |
+
env.get("USE_SEARCH_TELEMETRY", "false").lower() in _TRUE
|
| 30 |
+
and env.get("SEARCH_TELEMETRY_STDOUT", "true").lower() in _TRUE
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _prefixed_key(key: str, value: Any) -> str:
|
| 35 |
+
"""deepsearch-api's JSONStdoutSpanExporter._get_prefixed_key, for scalars."""
|
| 36 |
+
if key in ("time", "timestamp") and isinstance(value, int) and 1000000000 <= value <= 9999999999999:
|
| 37 |
+
return f"ts_{key}"
|
| 38 |
+
if isinstance(value, bool):
|
| 39 |
+
return f"b_{key}"
|
| 40 |
+
if isinstance(value, int):
|
| 41 |
+
return f"d_{key}" if key.endswith("_ms") or "time_ms" in key else f"l_{key}"
|
| 42 |
+
if isinstance(value, float):
|
| 43 |
+
return f"d_{key}"
|
| 44 |
+
return f"s_{key}"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def record(**attributes: Any) -> dict:
|
| 48 |
+
"""The prefixed event; ``None`` values are dropped, as deepsearch-api does."""
|
| 49 |
+
event = {
|
| 50 |
+
"time": int(time.time() * 1000),
|
| 51 |
+
"service": "package-download",
|
| 52 |
+
"telemetry_key": "asset_load_total",
|
| 53 |
+
"event_type": "asset_load",
|
| 54 |
+
"username": "anonymous",
|
| 55 |
+
**attributes,
|
| 56 |
+
}
|
| 57 |
+
if isinstance(event.get("asset_url"), str):
|
| 58 |
+
event["asset_url"] = event["asset_url"][:1024]
|
| 59 |
+
return {_prefixed_key(k, v): v for k, v in event.items() if v is not None}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def emit(**attributes: Any) -> None:
|
| 63 |
+
if not enabled():
|
| 64 |
+
return
|
| 65 |
+
try:
|
| 66 |
+
logger.info(json.dumps(record(**attributes)))
|
| 67 |
+
except Exception: # noqa: BLE001 — telemetry must never break a download
|
| 68 |
+
logger.warning("Failed to record asset-load telemetry", exc_info=True)
|