Shoraky commited on
Commit
5208038
·
1 Parent(s): b44bd66

Wait for analysis readiness before starting API

Browse files
Files changed (3) hide show
  1. README.md +1 -1
  2. start.py +28 -2
  3. 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`. AI health is internal at `http://127.0.0.1:8000/healthz` and becomes available after model initialization. Node health alone does not indicate model readiness. 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
 
 
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
- children.append(subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app",
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()