uranusmediaconn Claude Code commited on
Commit
45dd526
·
1 Parent(s): 5db9737

feat: container entrypoint provisions Redis + supervises Celery worker

Browse files

- entrypoint.py: resolves REDIS_URL/CELERY_BROKER_URL/CELERY_RESULT_BACKEND
from env (Space secrets win), else provisions Upstash free-tier DBs with
deterministic v4-form idempotency keys (restart-stable, re-fetchable);
runs Celery worker + uvicorn, restarts worker on death.
- celery_tasks.py: explicit broker_use_ssl/redis_backend_use_ssl with
CERT_REQUIRED for rediss:// brokers (TLS verification stays ON).
- Dockerfile / Dockerfile.hf: CMD -> python entrypoint.py.

Co-Authored-By: Claude Code <noreply@anthropic.com>

Files changed (4) hide show
  1. Dockerfile +3 -2
  2. Dockerfile.hf +1 -1
  3. app/tasks/celery_tasks.py +8 -0
  4. entrypoint.py +144 -0
Dockerfile CHANGED
@@ -12,9 +12,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
12
  COPY requirements.txt .
13
  RUN pip install --no-cache-dir -r requirements.txt
14
 
15
- # Copy app source
16
  COPY app/ ./app/
 
17
 
18
  EXPOSE 7860
19
 
20
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
12
  COPY requirements.txt .
13
  RUN pip install --no-cache-dir -r requirements.txt
14
 
15
+ # Copy app source + container entrypoint (provisions Redis, supervises worker)
16
  COPY app/ ./app/
17
+ COPY entrypoint.py .
18
 
19
  EXPOSE 7860
20
 
21
+ CMD ["python", "entrypoint.py"]
Dockerfile.hf CHANGED
@@ -16,4 +16,4 @@ COPY . .
16
 
17
  EXPOSE 7860
18
 
19
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
16
 
17
  EXPOSE 7860
18
 
19
+ CMD ["python", "entrypoint.py"]
app/tasks/celery_tasks.py CHANGED
@@ -33,6 +33,14 @@ celery_app.conf.update(
33
  task_reject_on_worker_lost=True,
34
  )
35
 
 
 
 
 
 
 
 
 
36
 
37
  @celery_app.task(bind=True, max_retries=3, rate_limit="100/m")
38
  def bulk_validate_task(self, emails: list, webhook_url: str = None):
 
33
  task_reject_on_worker_lost=True,
34
  )
35
 
36
+ # Hosted Redis (Upstash) arrives over rediss:// — always verify the server
37
+ # certificate. Explicit CERT_REQUIRED so Celery never silently downgrades.
38
+ import ssl as _ssl
39
+ if settings.celery_broker_url.startswith("rediss"):
40
+ celery_app.conf.broker_use_ssl = {"ssl_cert_reqs": _ssl.CERT_REQUIRED}
41
+ if settings.celery_result_backend.startswith("rediss"):
42
+ celery_app.conf.redis_backend_use_ssl = {"ssl_cert_reqs": _ssl.CERT_REQUIRED}
43
+
44
 
45
  @celery_app.task(bind=True, max_retries=3, rate_limit="100/m")
46
  def bulk_validate_task(self, emails: list, webhook_url: str = None):
entrypoint.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Container entrypoint for BRIDGE Modul-X on Hugging Face Spaces.
4
+
5
+ Responsibilities
6
+ ----------------
7
+ 1. Ensure Redis/Celery env vars point at a reachable Redis:
8
+ - Space secrets win: REDIS_URL / CELERY_BROKER_URL / CELERY_RESULT_BACKEND.
9
+ - Otherwise provision Upstash free-tier databases (cache, celery-broker,
10
+ celery-result) via the unauthenticated start-redis endpoint with
11
+ *stable* Idempotency-Keys, so every container restart re-fetches the
12
+ same databases. Free DBs expire after ~3 days unless claimed at the
13
+ console URL printed in the boot logs (or unless the account-backed
14
+ secrets above are set).
15
+ 2. Supervise two child processes:
16
+ - Celery worker (bulk validation queue)
17
+ - uvicorn (FastAPI web app on $PORT / 7860)
18
+ The worker is restarted if it dies; if the web process dies the
19
+ container exits and the platform restarts it.
20
+ """
21
+ import os
22
+ import signal
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ import uuid
27
+
28
+ # Fixed namespace so the per-store Idempotency-Keys are deterministic.
29
+ _NS = uuid.UUID("7c9e6679-7425-40de-944b-e07fc1f90ae7")
30
+
31
+
32
+ def _stable_db_id(store: str) -> str:
33
+ """Deterministic UUIDv4-FORM id (Upstash rejects non-v4 idempotency keys):
34
+ derive bytes from uuid5, then patch version/variant bits to v4/RFC-4122."""
35
+ b = bytearray(uuid.uuid5(_NS, f"bridge-modul-x/{store}").bytes)
36
+ b[6] = (b[6] & 0x0F) | 0x40
37
+ b[8] = (b[8] & 0x3F) | 0x80
38
+ return str(uuid.UUID(bytes=bytes(b)))
39
+
40
+
41
+ def _provision(store: str):
42
+ """Create/re-fetch a free-tier Upstash DB. Returns a rediss:// URL or None."""
43
+ from app.utils.upstash_free import create_database
44
+
45
+ # First try the stable key (re-fetch after restart); if Upstash rejects
46
+ # it (expired DB), fall back to one fresh random key.
47
+ for attempt, db_id in enumerate([_stable_db_id(store), ""]):
48
+ try:
49
+ db = create_database(db_id=db_id)
50
+ print(
51
+ f"[ENTRYPOINT] {store}: upstash db {db.db_id} ok "
52
+ f"(expires {db.expires}) - claim to keep it: {db.console_url}",
53
+ flush=True,
54
+ )
55
+ return db.connection_string(0)
56
+ except Exception as exc:
57
+ print(f"[ENTRYPOINT] {store}: provision failed (try {attempt}): {exc}", flush=True)
58
+ return None
59
+
60
+
61
+ def _resolve(env_name: str, store: str):
62
+ url = os.environ.get(env_name, "").strip()
63
+ if url and "localhost" not in url and "127.0.0.1" not in url:
64
+ return url
65
+ print(f"[ENTRYPOINT] {env_name} unset/local - provisioning '{store}' on Upstash free tier", flush=True)
66
+ return _provision(store)
67
+
68
+
69
+ def main():
70
+ cache_url = _resolve("REDIS_URL", "cache")
71
+ broker_url = _resolve("CELERY_BROKER_URL", "celery-broker") or cache_url
72
+ result_url = _resolve("CELERY_RESULT_BACKEND", "celery-result") or broker_url
73
+
74
+ if cache_url:
75
+ os.environ["REDIS_URL"] = cache_url
76
+ if broker_url:
77
+ os.environ["CELERY_BROKER_URL"] = broker_url
78
+ if result_url:
79
+ os.environ["CELERY_RESULT_BACKEND"] = result_url
80
+ if not broker_url:
81
+ print("[ENTRYPOINT] WARNING: no Redis reachable - bulk jobs use the synchronous fallback", flush=True)
82
+
83
+ port = os.environ.get("PORT", "7860")
84
+ children = {}
85
+ state = {"stopping": False}
86
+
87
+ def spawn(kind):
88
+ if kind == "worker":
89
+ cmd = [
90
+ sys.executable, "-m", "celery",
91
+ "-A", "app.tasks.celery_tasks:celery_app", "worker",
92
+ "--loglevel=info", "--concurrency=4",
93
+ ]
94
+ else:
95
+ cmd = [
96
+ sys.executable, "-m", "uvicorn", "app.main:app",
97
+ "--host", "0.0.0.0", "--port", port,
98
+ ]
99
+ proc = subprocess.Popen(cmd, env=os.environ)
100
+ children[kind] = proc
101
+ print(f"[ENTRYPOINT] started {kind} (pid {proc.pid})", flush=True)
102
+ return proc
103
+
104
+ def on_signal(signum, frame):
105
+ state["stopping"] = True
106
+ for proc in list(children.values()):
107
+ try:
108
+ proc.terminate()
109
+ except Exception:
110
+ pass
111
+
112
+ signal.signal(signal.SIGTERM, on_signal)
113
+ signal.signal(signal.SIGINT, on_signal)
114
+
115
+ spawn("worker")
116
+ web = spawn("web")
117
+
118
+ while not state["stopping"]:
119
+ if web.poll() is not None:
120
+ print(f"[ENTRYPOINT] web exited ({web.returncode}) - shutting down", flush=True)
121
+ break
122
+ worker = children["worker"]
123
+ if worker.poll() is not None:
124
+ print(f"[ENTRYPOINT] worker exited ({worker.returncode}) - restarting in 5s", flush=True)
125
+ time.sleep(5)
126
+ if not state["stopping"]:
127
+ spawn("worker")
128
+ time.sleep(2)
129
+
130
+ for proc in list(children.values()):
131
+ try:
132
+ proc.terminate()
133
+ proc.wait(timeout=10)
134
+ except Exception:
135
+ try:
136
+ proc.kill()
137
+ except Exception:
138
+ pass
139
+
140
+ sys.exit(web.returncode or 0)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()