Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
feat: secure metrics ingestion
#1
by burtenshaw HF Staff - opened
- .gitignore +3 -0
- README.md +31 -1
- app.py +22 -2
- ingest.py +150 -0
- test_ingest.py +256 -0
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
.pytest_cache/
|
| 3 |
+
.ruff_cache/
|
README.md
CHANGED
|
@@ -12,5 +12,35 @@ hf_oauth_scopes:
|
|
| 12 |
---
|
| 13 |
|
| 14 |
Every [OpenEnv Arena](https://huggingface.co/spaces/openenvarena/arena) training run streams its numbers here while it
|
| 15 |
-
trains: reward, loss and how long each step took.
|
| 16 |
arena's own submissions page links each run to its curves. Built with [Trackio](https://github.com/gradio-app/trackio).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
Every [OpenEnv Arena](https://huggingface.co/spaces/openenvarena/arena) training run streams its numbers here while it
|
| 15 |
+
trains: reward, loss and how long each step took. Each participant has a project with one run per training run, and the
|
| 16 |
arena's own submissions page links each run to its curves. Built with [Trackio](https://github.com/gradio-app/trackio).
|
| 17 |
+
|
| 18 |
+
## Worker ingestion
|
| 19 |
+
|
| 20 |
+
Set the Space secret `TRACKIO_INGEST_TOKEN` to the same dedicated random credential
|
| 21 |
+
configured on Arena's workers. It only authorizes `POST /ingest`; it is not a Hugging
|
| 22 |
+
Face token or Trackio's dashboard administration token. When unset, ingestion is
|
| 23 |
+
disabled and the dashboard continues serving existing runs.
|
| 24 |
+
|
| 25 |
+
Send `Authorization: Bearer <credential>` with a JSON body:
|
| 26 |
+
|
| 27 |
+
```json
|
| 28 |
+
{"logs":[{"project":"burtenshaw","run":"arena-run-id","run_id":"arena-run-id","step":1,"log_id":"unique-event-id","metrics":{"reward":0.75,"arena/step_seconds":1.2},"config":{"submission_id":"submission-id"}}]}
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
A batch contains 1–128 events for one project and run, up to 256 KiB. Each event
|
| 32 |
+
contains 1–128 finite numeric metrics. Project, run and event IDs contain only
|
| 33 |
+
ASCII letters, digits, `_` and `-`, up to 128 characters. `run_id` may be omitted;
|
| 34 |
+
when present it must equal `run`. Preserve each `log_id` when retrying delivery;
|
| 35 |
+
duplicates cannot replace existing metrics. Distinct events at the same step need
|
| 36 |
+
distinct IDs.
|
| 37 |
+
|
| 38 |
+
Optional config accepts strings `participant`, `submission_id`, `run_id`, `model`,
|
| 39 |
+
`model_revision`, `recipe`, and nonnegative numbers `num_updates`, `train_tasks`.
|
| 40 |
+
Supply it on the first batch; it cannot be changed after a run exists. Repeating
|
| 41 |
+
the same config on subsequent events is supported.
|
| 42 |
+
|
| 43 |
+
The existing `openenvarena/training-dashboard` bucket stays mounted at `/data`, with
|
| 44 |
+
`TRACKIO_DIR=/data/trackio`. The app uses this mounted storage directly and does not
|
| 45 |
+
provision Spaces, buckets or credentials. Run one server process for Trackio's
|
| 46 |
+
bucket-backed SQLite storage. Trackio remains pinned to `0.40.0`.
|
app.py
CHANGED
|
@@ -1,2 +1,22 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
import uvicorn
|
| 4 |
+
from starlette.routing import Route
|
| 5 |
+
from trackio.server import build_starlette_app_only
|
| 6 |
+
|
| 7 |
+
from ingest import ingest
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def create_app():
|
| 11 |
+
# Keep Trackio's dashboard, authentication and mounted bucket configuration.
|
| 12 |
+
app, _ = build_starlette_app_only(mcp_server=False)
|
| 13 |
+
app.router.routes.insert(0, Route("/ingest", ingest, methods=["POST"]))
|
| 14 |
+
return app
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
if __name__ == "__main__":
|
| 18 |
+
uvicorn.run(
|
| 19 |
+
create_app(),
|
| 20 |
+
host="0.0.0.0",
|
| 21 |
+
port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
|
| 22 |
+
)
|
ingest.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Append-only numeric metric ingestion for trusted Arena workers."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import secrets
|
| 8 |
+
from threading import Lock
|
| 9 |
+
|
| 10 |
+
from starlette.concurrency import run_in_threadpool
|
| 11 |
+
from starlette.exceptions import HTTPException
|
| 12 |
+
from starlette.responses import JSONResponse
|
| 13 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 14 |
+
|
| 15 |
+
MAX_BODY_BYTES = 256 * 1024
|
| 16 |
+
MAX_LOGS = 128
|
| 17 |
+
MAX_METRICS = 128
|
| 18 |
+
IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}\Z")
|
| 19 |
+
STRING_CONFIG = {
|
| 20 |
+
"participant",
|
| 21 |
+
"submission_id",
|
| 22 |
+
"run_id",
|
| 23 |
+
"model",
|
| 24 |
+
"model_revision",
|
| 25 |
+
"recipe",
|
| 26 |
+
}
|
| 27 |
+
NUMBER_CONFIG = {"num_updates", "train_tasks"}
|
| 28 |
+
LOG_FIELDS = {"project", "run", "run_id", "step", "log_id", "metrics", "config"}
|
| 29 |
+
# Trackio's bucket-backed SQLite storage supports one server process. Serialize
|
| 30 |
+
# the configuration check with ingestion so two first batches cannot replace it.
|
| 31 |
+
_write_lock = Lock()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _number(value):
|
| 35 |
+
if type(value) is int:
|
| 36 |
+
return -(2**63) <= value <= 2**63 - 1
|
| 37 |
+
return type(value) is float and math.isfinite(value)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _identifier(value):
|
| 41 |
+
return isinstance(value, str) and IDENTIFIER.fullmatch(value) is not None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _config(value):
|
| 45 |
+
if value is None:
|
| 46 |
+
return None
|
| 47 |
+
if not isinstance(value, dict) or value.keys() - STRING_CONFIG - NUMBER_CONFIG:
|
| 48 |
+
raise ValueError("config contains unsupported fields")
|
| 49 |
+
for key, item in value.items():
|
| 50 |
+
if key in STRING_CONFIG:
|
| 51 |
+
if not isinstance(item, str) or len(item) > 512:
|
| 52 |
+
raise ValueError(
|
| 53 |
+
"config string values must have at most 512 characters"
|
| 54 |
+
)
|
| 55 |
+
item.encode("utf-8")
|
| 56 |
+
elif not _number(item) or item < 0:
|
| 57 |
+
raise ValueError("config counts must be finite nonnegative numbers")
|
| 58 |
+
return value or None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def validate(payload):
|
| 62 |
+
if not isinstance(payload, dict) or set(payload) != {"logs"}:
|
| 63 |
+
raise ValueError("expected an object containing only logs")
|
| 64 |
+
logs = payload["logs"]
|
| 65 |
+
if not isinstance(logs, list) or not 1 <= len(logs) <= MAX_LOGS:
|
| 66 |
+
raise ValueError(f"logs must contain 1 to {MAX_LOGS} entries")
|
| 67 |
+
identity = None
|
| 68 |
+
config = None
|
| 69 |
+
for entry in logs:
|
| 70 |
+
if not isinstance(entry, dict) or entry.keys() - LOG_FIELDS:
|
| 71 |
+
raise ValueError("log contains unsupported fields")
|
| 72 |
+
for key in ("project", "run", "log_id"):
|
| 73 |
+
if not _identifier(entry.get(key)):
|
| 74 |
+
raise ValueError(f"{key} must contain 1 to 128 letters, digits, _ or -")
|
| 75 |
+
if entry.get("run_id", entry["run"]) != entry["run"]:
|
| 76 |
+
raise ValueError("run_id must equal run")
|
| 77 |
+
current = (entry["project"], entry["run"])
|
| 78 |
+
if identity is not None and current != identity:
|
| 79 |
+
raise ValueError("a batch must contain one project and run")
|
| 80 |
+
identity = current
|
| 81 |
+
step = entry.get("step")
|
| 82 |
+
if type(step) is not int or not 0 <= step <= 2**63 - 1:
|
| 83 |
+
raise ValueError("step must be a nonnegative 64-bit integer")
|
| 84 |
+
metrics = entry.get("metrics")
|
| 85 |
+
if not isinstance(metrics, dict) or not 1 <= len(metrics) <= MAX_METRICS:
|
| 86 |
+
raise ValueError(f"metrics must contain 1 to {MAX_METRICS} numeric values")
|
| 87 |
+
for key, value in metrics.items():
|
| 88 |
+
if not isinstance(key, str) or not 1 <= len(key) <= 128:
|
| 89 |
+
raise ValueError("metric names must contain 1 to 128 characters")
|
| 90 |
+
key.encode("utf-8")
|
| 91 |
+
if not _number(value):
|
| 92 |
+
raise ValueError("metrics must contain only finite numbers")
|
| 93 |
+
entry_config = _config(entry.get("config"))
|
| 94 |
+
if entry_config is not None:
|
| 95 |
+
if config is not None and entry_config != config:
|
| 96 |
+
raise ValueError("config must be consistent within a batch")
|
| 97 |
+
config = entry_config
|
| 98 |
+
return logs, identity, config
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def append(logs, identity, config):
|
| 102 |
+
project, run = identity
|
| 103 |
+
with _write_lock:
|
| 104 |
+
existing = next(
|
| 105 |
+
(
|
| 106 |
+
record
|
| 107 |
+
for record in SQLiteStorage.get_run_records(project)
|
| 108 |
+
if record["id"] == run
|
| 109 |
+
),
|
| 110 |
+
None,
|
| 111 |
+
)
|
| 112 |
+
stored = SQLiteStorage.get_run_config(project, run, run_id=run)
|
| 113 |
+
if existing is not None or stored is not None:
|
| 114 |
+
if config is not None and config != stored:
|
| 115 |
+
raise HTTPException(
|
| 116 |
+
409, "configuration of an existing run is immutable"
|
| 117 |
+
)
|
| 118 |
+
config = None
|
| 119 |
+
SQLiteStorage.bulk_log(
|
| 120 |
+
project=project,
|
| 121 |
+
run=run,
|
| 122 |
+
run_id=run,
|
| 123 |
+
metrics_list=[entry["metrics"] for entry in logs],
|
| 124 |
+
steps=[entry["step"] for entry in logs],
|
| 125 |
+
# Scope event IDs to the run; retries cannot replace existing rows.
|
| 126 |
+
log_ids=[f"{run}:{entry['log_id']}" for entry in logs],
|
| 127 |
+
config=config,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def ingest(request):
|
| 132 |
+
expected = os.environ.get("TRACKIO_INGEST_TOKEN", "")
|
| 133 |
+
if not expected:
|
| 134 |
+
return JSONResponse(
|
| 135 |
+
{"error": "metric ingestion is not configured"}, status_code=503
|
| 136 |
+
)
|
| 137 |
+
supplied = request.headers.get("authorization", "")
|
| 138 |
+
if not secrets.compare_digest(supplied.encode(), f"Bearer {expected}".encode()):
|
| 139 |
+
return JSONResponse({"error": "invalid ingest credential"}, status_code=401)
|
| 140 |
+
body = bytearray()
|
| 141 |
+
async for chunk in request.stream():
|
| 142 |
+
if len(body) + len(chunk) > MAX_BODY_BYTES:
|
| 143 |
+
return JSONResponse({"error": "metric batch is too large"}, status_code=413)
|
| 144 |
+
body.extend(chunk)
|
| 145 |
+
try:
|
| 146 |
+
logs, identity, config = validate(json.loads(body))
|
| 147 |
+
except (ValueError, OverflowError, RecursionError):
|
| 148 |
+
return JSONResponse({"error": "invalid metric batch"}, status_code=400)
|
| 149 |
+
await run_in_threadpool(append, logs, identity, config)
|
| 150 |
+
return JSONResponse({"accepted": len(logs)})
|
test_ingest.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
import subprocess
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from starlette.applications import Starlette
|
| 10 |
+
from starlette.routing import Route
|
| 11 |
+
from starlette.testclient import TestClient
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def client(tmp_path, monkeypatch):
|
| 16 |
+
import trackio.sqlite_storage
|
| 17 |
+
import trackio.utils
|
| 18 |
+
|
| 19 |
+
monkeypatch.setattr(trackio.sqlite_storage, "TRACKIO_DIR", tmp_path)
|
| 20 |
+
monkeypatch.setattr(trackio.utils, "TRACKIO_DIR", tmp_path)
|
| 21 |
+
monkeypatch.setenv("TRACKIO_INGEST_TOKEN", "test-ingest-token")
|
| 22 |
+
module = importlib.import_module("ingest")
|
| 23 |
+
app = Starlette(routes=[Route("/ingest", module.ingest, methods=["POST"])])
|
| 24 |
+
with TestClient(app) as test_client:
|
| 25 |
+
test_client.headers["Authorization"] = "Bearer test-ingest-token"
|
| 26 |
+
yield test_client
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def batch(**updates):
|
| 30 |
+
entry = {
|
| 31 |
+
"project": "burtenshaw",
|
| 32 |
+
"run": "arena-run-123",
|
| 33 |
+
"run_id": "arena-run-123",
|
| 34 |
+
"step": 1,
|
| 35 |
+
"log_id": "event-1",
|
| 36 |
+
"metrics": {"reward/mean": 0.75, "arena/step_seconds": 1.2},
|
| 37 |
+
"config": {
|
| 38 |
+
"participant": "space:burtenshaw",
|
| 39 |
+
"submission_id": "submission-123",
|
| 40 |
+
},
|
| 41 |
+
}
|
| 42 |
+
entry.update(updates)
|
| 43 |
+
return {"logs": [entry]}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_missing_or_invalid_auth_cannot_write(client, monkeypatch):
|
| 47 |
+
for authorization in ("", "Bearer wrong", "test-ingest-token"):
|
| 48 |
+
assert (
|
| 49 |
+
client.post(
|
| 50 |
+
"/ingest", json=batch(), headers={"Authorization": authorization}
|
| 51 |
+
).status_code
|
| 52 |
+
== 401
|
| 53 |
+
)
|
| 54 |
+
monkeypatch.delenv("TRACKIO_INGEST_TOKEN")
|
| 55 |
+
assert client.post("/ingest", json=batch()).status_code == 503
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@pytest.mark.parametrize(
|
| 59 |
+
"updates",
|
| 60 |
+
[
|
| 61 |
+
{"project": "../another-project"},
|
| 62 |
+
{"run_id": "another-run"},
|
| 63 |
+
{"step": -1},
|
| 64 |
+
{"step": True},
|
| 65 |
+
{"log_id": ""},
|
| 66 |
+
{"metrics": {"loss": "private text"}},
|
| 67 |
+
{"metrics": {"loss": True}},
|
| 68 |
+
{"metrics": {"loss": 2**80}},
|
| 69 |
+
{"metrics": {"loss": {"_type": "html", "html": "<script>"}}},
|
| 70 |
+
{"metrics": {str(i): i for i in range(129)}},
|
| 71 |
+
{"config": {"HF_TOKEN": "not-allowed"}},
|
| 72 |
+
{"config": {"model": ["not-a-string"]}},
|
| 73 |
+
{"config": {"num_updates": 2**80}},
|
| 74 |
+
{"delete": True},
|
| 75 |
+
],
|
| 76 |
+
)
|
| 77 |
+
def test_invalid_payload_rejected(client, updates):
|
| 78 |
+
assert client.post("/ingest", json=batch(**updates)).status_code == 400
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_nonfinite_and_oversized_payload_rejected(client):
|
| 82 |
+
assert (
|
| 83 |
+
client.post(
|
| 84 |
+
"/ingest", content=json.dumps(batch(metrics={"loss": float("nan")}))
|
| 85 |
+
).status_code
|
| 86 |
+
== 400
|
| 87 |
+
)
|
| 88 |
+
assert (
|
| 89 |
+
client.post(
|
| 90 |
+
"/ingest", content=json.dumps(batch(metrics={"loss": float("inf")}))
|
| 91 |
+
).status_code
|
| 92 |
+
== 400
|
| 93 |
+
)
|
| 94 |
+
assert client.post("/ingest", content=b" " * (256 * 1024 + 1)).status_code == 413
|
| 95 |
+
assert (
|
| 96 |
+
client.post("/ingest", json={"logs": batch()["logs"] * 129}).status_code == 400
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@pytest.mark.parametrize(
|
| 101 |
+
"updates",
|
| 102 |
+
[
|
| 103 |
+
{"metrics": {"\ud800": 0.2}},
|
| 104 |
+
{"config": {"model": "\ud800"}},
|
| 105 |
+
],
|
| 106 |
+
)
|
| 107 |
+
def test_unpaired_surrogates_rejected(client, updates):
|
| 108 |
+
response = client.post("/ingest", content=json.dumps(batch(**updates)))
|
| 109 |
+
assert response.status_code == 400
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_retry_is_idempotent_and_cannot_overwrite_metric(client):
|
| 113 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 114 |
+
|
| 115 |
+
assert client.post("/ingest", json=batch()).json() == {"accepted": 1}
|
| 116 |
+
assert client.post("/ingest", json=batch()).status_code == 200
|
| 117 |
+
assert (
|
| 118 |
+
client.post("/ingest", json=batch(metrics={"reward/mean": 999})).status_code
|
| 119 |
+
== 200
|
| 120 |
+
)
|
| 121 |
+
with SQLiteStorage._get_connection(
|
| 122 |
+
SQLiteStorage.get_project_db_path("burtenshaw")
|
| 123 |
+
) as conn:
|
| 124 |
+
rows = conn.execute("SELECT metrics FROM metrics").fetchall()
|
| 125 |
+
assert len(rows) == 1
|
| 126 |
+
assert json.loads(rows[0][0])["reward/mean"] == 0.75
|
| 127 |
+
assert (
|
| 128 |
+
client.post("/ingest", json=batch(log_id="event-2", config=None)).status_code
|
| 129 |
+
== 200
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_existing_config_cannot_be_changed(client):
|
| 134 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 135 |
+
|
| 136 |
+
assert client.post("/ingest", json=batch()).status_code == 200
|
| 137 |
+
assert (
|
| 138 |
+
client.post(
|
| 139 |
+
"/ingest", json=batch(config={"model": "changed"}, log_id="event-2")
|
| 140 |
+
).status_code
|
| 141 |
+
== 409
|
| 142 |
+
)
|
| 143 |
+
assert (
|
| 144 |
+
SQLiteStorage.get_run_config(
|
| 145 |
+
"burtenshaw", "arena-run-123", run_id="arena-run-123"
|
| 146 |
+
)
|
| 147 |
+
== batch()["logs"][0]["config"]
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_config_cannot_be_added_to_an_existing_run(client):
|
| 152 |
+
assert client.post("/ingest", json=batch(config=None)).status_code == 200
|
| 153 |
+
assert client.post("/ingest", json=batch(log_id="event-2")).status_code == 409
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_config_without_metric_rows_cannot_be_overwritten(client):
|
| 157 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 158 |
+
|
| 159 |
+
assert client.post("/ingest", json=batch()).status_code == 200
|
| 160 |
+
with SQLiteStorage._get_connection(
|
| 161 |
+
SQLiteStorage.get_project_db_path("burtenshaw")
|
| 162 |
+
) as conn:
|
| 163 |
+
conn.execute("DELETE FROM metrics")
|
| 164 |
+
assert SQLiteStorage.get_run_records("burtenshaw") == []
|
| 165 |
+
assert (
|
| 166 |
+
client.post("/ingest", json=batch(config={"model": "changed"})).status_code
|
| 167 |
+
== 409
|
| 168 |
+
)
|
| 169 |
+
assert (
|
| 170 |
+
SQLiteStorage.get_all_run_configs("burtenshaw")["arena-run-123"]
|
| 171 |
+
== batch()["logs"][0]["config"]
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_batches_cannot_mix_runs_or_mutate_partial_data(client):
|
| 176 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 177 |
+
|
| 178 |
+
payload = {"logs": batch()["logs"] + batch(run="another", run_id="another")["logs"]}
|
| 179 |
+
assert client.post("/ingest", json=payload).status_code == 400
|
| 180 |
+
assert SQLiteStorage.get_run_records("burtenshaw") == []
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_dashboard_still_serves_without_ingest_credentials(client, monkeypatch):
|
| 184 |
+
import huggingface_hub
|
| 185 |
+
from app import create_app
|
| 186 |
+
|
| 187 |
+
def unexpected_mutation(*args, **kwargs):
|
| 188 |
+
pytest.fail("starting the dashboard must not provision or upload Hub resources")
|
| 189 |
+
|
| 190 |
+
for name in (
|
| 191 |
+
"create_repo",
|
| 192 |
+
"create_bucket",
|
| 193 |
+
"set_space_volumes",
|
| 194 |
+
"upload_file",
|
| 195 |
+
"add_space_secret",
|
| 196 |
+
):
|
| 197 |
+
monkeypatch.setattr(huggingface_hub, name, unexpected_mutation)
|
| 198 |
+
monkeypatch.setattr(huggingface_hub.HfApi, name, unexpected_mutation)
|
| 199 |
+
monkeypatch.delenv("TRACKIO_INGEST_TOKEN")
|
| 200 |
+
monkeypatch.setenv("TRACKIO_DIR", "/data/trackio")
|
| 201 |
+
with TestClient(create_app()) as dashboard:
|
| 202 |
+
page = dashboard.get("/")
|
| 203 |
+
assert page.status_code == 200
|
| 204 |
+
asset = re.search(r'<script[^>]+src="([^"]+)"', page.text)
|
| 205 |
+
assert asset is not None
|
| 206 |
+
assert dashboard.get(asset.group(1)).status_code == 200
|
| 207 |
+
assert dashboard.post("/ingest", json=batch()).status_code == 503
|
| 208 |
+
assert os.environ["TRACKIO_DIR"] == "/data/trackio"
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_metrics_survive_fresh_process_with_same_storage(client, tmp_path):
|
| 212 |
+
assert client.post("/ingest", json=batch()).status_code == 200
|
| 213 |
+
result = subprocess.run(
|
| 214 |
+
[
|
| 215 |
+
sys.executable,
|
| 216 |
+
"-c",
|
| 217 |
+
"""
|
| 218 |
+
import json
|
| 219 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 220 |
+
rows = SQLiteStorage.get_run_records('burtenshaw')
|
| 221 |
+
assert rows[0]['id'] == 'arena-run-123'
|
| 222 |
+
with SQLiteStorage._get_connection(SQLiteStorage.get_project_db_path('burtenshaw')) as conn:
|
| 223 |
+
row = conn.execute('SELECT metrics FROM metrics').fetchone()
|
| 224 |
+
assert json.loads(row[0])['reward/mean'] == 0.75
|
| 225 |
+
print('persisted metrics recovered')
|
| 226 |
+
""",
|
| 227 |
+
],
|
| 228 |
+
env={**os.environ, "TRACKIO_DIR": str(tmp_path)},
|
| 229 |
+
capture_output=True,
|
| 230 |
+
text=True,
|
| 231 |
+
check=True,
|
| 232 |
+
)
|
| 233 |
+
assert "persisted metrics recovered" in result.stdout
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def test_dashboard_does_not_accept_ingest_token_for_delete_or_upload(
|
| 237 |
+
client, monkeypatch
|
| 238 |
+
):
|
| 239 |
+
monkeypatch.setenv("SYSTEM", "spaces")
|
| 240 |
+
from app import create_app
|
| 241 |
+
|
| 242 |
+
assert client.post("/ingest", json=batch()).status_code == 200
|
| 243 |
+
with TestClient(create_app()) as dashboard:
|
| 244 |
+
dashboard.headers["Authorization"] = "Bearer test-ingest-token"
|
| 245 |
+
response = dashboard.post(
|
| 246 |
+
"/api/delete_run",
|
| 247 |
+
json={"kwargs": {"project": "burtenshaw", "run": "arena-run-123"}},
|
| 248 |
+
)
|
| 249 |
+
assert response.status_code >= 400 or response.json().get("error")
|
| 250 |
+
response = dashboard.post(
|
| 251 |
+
"/api/upload_db_to_space",
|
| 252 |
+
json={
|
| 253 |
+
"kwargs": {"project": "burtenshaw", "uploaded_db": {}, "hf_token": None}
|
| 254 |
+
},
|
| 255 |
+
)
|
| 256 |
+
assert response.status_code >= 400 or response.json().get("error")
|