Spaces:
Running
Running
Wait for analysis readiness before starting API
Browse files- README.md +1 -1
- start.py +28 -2
- tests/test_start.py +54 -0
README.md
CHANGED
|
@@ -50,7 +50,7 @@ The worker accepts one analysis at a time. The backend stages inputs and receive
|
|
| 50 |
|
| 51 |
For a local Docker check, build with `docker build -t sporalize-backend .`, then run `docker run --rm --env-file .env -e PORT=7860 -p 7860:7860 sporalize-backend`. The `.env` NODE_ENV setting controls whether production HTTPS checks apply.
|
| 52 |
|
| 53 |
-
Node health: `GET /health`.
|
| 54 |
|
| 55 |
Run one container with one Python worker: analysis jobs and retry payloads are in memory and are lost on restart. Temporary local files are ephemeral. Completed videos and `results.json` are stored in the private HF dataset; Node stores session metadata and frame indices in Supabase. Model constants are in `app.py`.
|
| 56 |
|
|
|
|
| 50 |
|
| 51 |
For a local Docker check, build with `docker build -t sporalize-backend .`, then run `docker run --rm --env-file .env -e PORT=7860 -p 7860:7860 sporalize-backend`. The `.env` NODE_ENV setting controls whether production HTTPS checks apply.
|
| 52 |
|
| 53 |
+
Node health: `GET /health`. At startup, `start.py` waits for the internal AI health endpoint at `http://127.0.0.1:8000/healthz` before starting Node, so the public API opens only after model initialization. This readiness check runs once at startup and adds no latency to normal requests. If AI startup fails or takes longer than 25 minutes, the service exits with a clear error; Hugging Face's default Space startup timeout is 30 minutes. Video uploads go through Node directly to Python without Vercel. Validate hosting timeouts and representative large uploads before release.
|
| 54 |
|
| 55 |
Run one container with one Python worker: analysis jobs and retry payloads are in memory and are lost on restart. Temporary local files are ephemeral. Completed videos and `results.json` are stored in the private HF dataset; Node stores session metadata and frame indices in Supabase. Model constants are in `app.py`.
|
| 56 |
|
start.py
CHANGED
|
@@ -1,14 +1,37 @@
|
|
| 1 |
"""Run the public Node API and private Python worker as one service."""
|
|
|
|
| 2 |
import os
|
| 3 |
from pathlib import Path
|
| 4 |
import signal
|
| 5 |
import subprocess
|
| 6 |
import sys
|
| 7 |
import time
|
|
|
|
| 8 |
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
def main():
|
| 13 |
root = Path(__file__).resolve().parent
|
| 14 |
load_dotenv(root / ".env")
|
|
@@ -28,8 +51,11 @@ def main():
|
|
| 28 |
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 29 |
signal.signal(sig, stop)
|
| 30 |
try:
|
| 31 |
-
|
| 32 |
-
"--host", "127.0.0.1", "--port", "8000", "--workers", "1"], cwd=root, env=env)
|
|
|
|
|
|
|
|
|
|
| 33 |
children.append(subprocess.Popen(["node", "dist/server.js"], cwd=root, env=env))
|
| 34 |
while not stopping:
|
| 35 |
for child in children:
|
|
|
|
| 1 |
"""Run the public Node API and private Python worker as one service."""
|
| 2 |
+
import json
|
| 3 |
import os
|
| 4 |
from pathlib import Path
|
| 5 |
import signal
|
| 6 |
import subprocess
|
| 7 |
import sys
|
| 8 |
import time
|
| 9 |
+
from urllib.request import urlopen
|
| 10 |
|
| 11 |
from dotenv import load_dotenv
|
| 12 |
|
| 13 |
|
| 14 |
+
def wait_for_analysis(process, should_stop, timeout_seconds=25 * 60):
|
| 15 |
+
deadline = time.monotonic() + timeout_seconds
|
| 16 |
+
while not should_stop():
|
| 17 |
+
exit_code = process.poll()
|
| 18 |
+
if exit_code is not None:
|
| 19 |
+
print(f"Analysis process exited before it was ready ({exit_code})", flush=True)
|
| 20 |
+
return False
|
| 21 |
+
try:
|
| 22 |
+
with urlopen("http://127.0.0.1:8000/healthz", timeout=1) as response:
|
| 23 |
+
payload = json.load(response) if response.status == 200 else None
|
| 24 |
+
if isinstance(payload, dict) and payload.get("ok") is True:
|
| 25 |
+
return process.poll() is None
|
| 26 |
+
except (OSError, ValueError):
|
| 27 |
+
pass
|
| 28 |
+
if time.monotonic() >= deadline:
|
| 29 |
+
print(f"Analysis process did not become ready within {timeout_seconds} seconds", flush=True)
|
| 30 |
+
return False
|
| 31 |
+
time.sleep(0.5)
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
|
| 35 |
def main():
|
| 36 |
root = Path(__file__).resolve().parent
|
| 37 |
load_dotenv(root / ".env")
|
|
|
|
| 51 |
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 52 |
signal.signal(sig, stop)
|
| 53 |
try:
|
| 54 |
+
analysis = subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app",
|
| 55 |
+
"--host", "127.0.0.1", "--port", "8000", "--workers", "1"], cwd=root, env=env)
|
| 56 |
+
children.append(analysis)
|
| 57 |
+
if not wait_for_analysis(analysis, lambda: stopping):
|
| 58 |
+
return 0 if stopping else 1
|
| 59 |
children.append(subprocess.Popen(["node", "dist/server.js"], cwd=root, env=env))
|
| 60 |
while not stopping:
|
| 61 |
for child in children:
|
tests/test_start.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 3 |
+
|
| 4 |
+
import start
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class StartupTest(unittest.TestCase):
|
| 8 |
+
def test_waits_for_analysis_health_before_accepting_requests(self):
|
| 9 |
+
process = Mock()
|
| 10 |
+
process.poll.return_value = None
|
| 11 |
+
response = MagicMock()
|
| 12 |
+
response.__enter__.return_value.status = 200
|
| 13 |
+
response.__enter__.return_value.read.return_value = b'{"ok": true}'
|
| 14 |
+
with patch.object(start, "urlopen", side_effect=[OSError("not listening"), response]) as probe, \
|
| 15 |
+
patch.object(start.time, "sleep") as sleep:
|
| 16 |
+
self.assertTrue(start.wait_for_analysis(process, lambda: False))
|
| 17 |
+
self.assertEqual(probe.call_count, 2)
|
| 18 |
+
sleep.assert_called_once_with(0.5)
|
| 19 |
+
|
| 20 |
+
def test_exits_without_starting_node_if_analysis_fails(self):
|
| 21 |
+
analysis = Mock()
|
| 22 |
+
analysis.poll.return_value = 3
|
| 23 |
+
analysis.returncode = 3
|
| 24 |
+
with patch.object(start.subprocess, "Popen", return_value=analysis) as spawn, \
|
| 25 |
+
patch.object(start, "load_dotenv"), patch.object(start.signal, "signal"):
|
| 26 |
+
self.assertEqual(start.main(), 1)
|
| 27 |
+
spawn.assert_called_once()
|
| 28 |
+
|
| 29 |
+
def test_times_out_if_analysis_never_becomes_ready(self):
|
| 30 |
+
analysis = Mock()
|
| 31 |
+
analysis.poll.return_value = None
|
| 32 |
+
with patch.object(start, "urlopen", side_effect=OSError("not listening")), \
|
| 33 |
+
patch.object(start.time, "monotonic", side_effect=[0, 2]), \
|
| 34 |
+
patch.object(start.time, "sleep") as sleep:
|
| 35 |
+
self.assertFalse(start.wait_for_analysis(analysis, lambda: False, timeout_seconds=1))
|
| 36 |
+
sleep.assert_not_called()
|
| 37 |
+
|
| 38 |
+
def test_starts_node_after_analysis_is_ready(self):
|
| 39 |
+
analysis = Mock()
|
| 40 |
+
analysis.poll.return_value = None
|
| 41 |
+
node = Mock()
|
| 42 |
+
node.poll.return_value = 2
|
| 43 |
+
node.returncode = 2
|
| 44 |
+
with patch.object(start.subprocess, "Popen", side_effect=[analysis, node]) as spawn, \
|
| 45 |
+
patch.object(start, "load_dotenv"), patch.object(start.signal, "signal"), \
|
| 46 |
+
patch.object(start, "wait_for_analysis", side_effect=lambda *_: spawn.call_count == 1) as readiness:
|
| 47 |
+
self.assertEqual(start.main(), 2)
|
| 48 |
+
self.assertEqual(spawn.call_count, 2)
|
| 49 |
+
self.assertEqual(spawn.call_args_list[1].args[0], ["node", "dist/server.js"])
|
| 50 |
+
readiness.assert_called_once()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
unittest.main()
|