github-actions[bot] commited on
Commit
4cae178
·
1 Parent(s): 80cb260

Deploy from GitHub Actions: 30bef6b9b66a9b531ac834512c3c8c5ca1ff3713

Browse files
Files changed (2) hide show
  1. requirements.txt +2 -1
  2. src/main.py +103 -51
requirements.txt CHANGED
@@ -5,4 +5,5 @@ pillow
5
  redis
6
  requests
7
  python-multipart
8
- python-dotenv
 
 
5
  redis
6
  requests
7
  python-multipart
8
+ python-dotenv
9
+ httpx
src/main.py CHANGED
@@ -1,14 +1,16 @@
1
  import os
2
  import io
3
  import json
4
- import time
5
  import logging
6
- import threading
7
- import requests
8
- import redis
9
- from dotenv import load_dotenv
10
  from PIL import Image
11
  from rembg import remove, new_session
 
 
 
12
  from fastapi import FastAPI
13
  from .config import MODEL_NAME, PREFIX
14
 
@@ -30,20 +32,27 @@ if not WEB_APP_URL:
30
 
31
  WORKER_SECRET = os.getenv("WORKER_SECRET")
32
 
33
- r = redis.Redis.from_url(REDIS_URL, decode_responses=True)
 
34
 
35
  session = new_session(MODEL_NAME)
36
 
37
- app = FastAPI(title="NoBG Worker")
 
 
 
38
 
 
 
39
 
40
- def upload_to_uploadthing(image_bytes: bytes, filename: str) -> str:
 
41
  url = f"{WEB_APP_URL}/api/worker/upload"
42
 
43
  files = {"file": (filename, image_bytes, "image/png")}
44
  headers = {"Authorization": f"Bearer {WORKER_SECRET}"} if WORKER_SECRET else {}
45
 
46
- response = requests.post(url, files=files, headers=headers)
47
 
48
  if response.status_code != 200:
49
  logger.error(f"Worker Upload API error: {response.status_code} - {response.text}")
@@ -59,7 +68,8 @@ def upload_to_uploadthing(image_bytes: bytes, filename: str) -> str:
59
  return blob_url
60
 
61
 
62
- def process_job(job_data_str: str):
 
63
  try:
64
  job = json.loads(job_data_str)
65
  job_id = job["id"]
@@ -68,59 +78,101 @@ def process_job(job_data_str: str):
68
 
69
  logger.info(f"Processing job {job_id} from {source_url}")
70
 
71
- response = requests.get(source_url)
72
- response.raise_for_status()
73
-
74
- r.hset(f"{PREFIX}:job_status:{job_id}", mapping={"status": "processing"})
75
-
76
- input_image = Image.open(io.BytesIO(response.content))
77
- output_image = remove(input_image, session=session)
78
-
79
- img_byte_arr = io.BytesIO()
80
- output_image.save(img_byte_arr, format="PNG")
81
- img_bytes = img_byte_arr.getvalue()
82
-
83
- name_parts = original_filename.rsplit(".", 1)
84
- if len(name_parts) == 2:
85
- base_name, ext = name_parts
86
- filename = f"{base_name}-nobg.{ext}"
87
- else:
88
- filename = f"{original_filename}-nobg.png"
89
-
90
- result_url = upload_to_uploadthing(img_bytes, filename)
91
-
92
- r.hset(
93
- f"{PREFIX}:job_status:{job_id}",
94
- mapping={"status": "completed", "result_url": result_url},
95
- )
96
- r.expire(f"{PREFIX}:job_status:{job_id}", 3600)
97
-
98
- logger.info(f"Job {job_id} completed successfully. URL: {result_url}")
 
 
 
 
 
 
 
 
 
99
 
100
  except Exception as e:
101
  logger.error(f"Error processing job: {str(e)}")
102
- if "job_id" in locals():
103
- r.hset(f"{PREFIX}:job_status:{job_id}", mapping={"status": "failed"})
104
- r.expire(f"{PREFIX}:job_status:{job_id}", 3600)
 
 
 
105
 
106
 
107
- def worker_loop():
108
  logger.info(f"Starting Redis worker loop... Listening on queue: {PREFIX}:job_queue")
109
  while True:
110
  try:
111
- result = r.brpop(f"{PREFIX}:job_queue", timeout=0)
112
- if result:
113
- _, job_data_str = result
114
- process_job(job_data_str)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  except Exception as e:
116
  logger.error(f"Redis connection/worker loop error: {str(e)}")
117
- time.sleep(5)
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
 
120
- @app.on_event("startup")
121
- def startup_event():
122
- thread = threading.Thread(target=worker_loop, daemon=True)
123
- thread.start()
124
 
125
 
126
  @app.get("/")
 
1
  import os
2
  import io
3
  import json
 
4
  import logging
5
+ import asyncio
6
+ import functools
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from contextlib import asynccontextmanager
9
  from PIL import Image
10
  from rembg import remove, new_session
11
+ import redis.asyncio as aioredis
12
+ import httpx
13
+ from dotenv import load_dotenv
14
  from fastapi import FastAPI
15
  from .config import MODEL_NAME, PREFIX
16
 
 
32
 
33
  WORKER_SECRET = os.getenv("WORKER_SECRET")
34
 
35
+ # Connect to Redis using the async engine
36
+ r = aioredis.from_url(REDIS_URL, decode_responses=True)
37
 
38
  session = new_session(MODEL_NAME)
39
 
40
+ # --- Concurrency Semaphores ---
41
+ # Limit parallel model inferences to 1 since HuggingFace Space Free Tier has 2 vCPUs
42
+ cpu_semaphore = asyncio.Semaphore(1)
43
+ cpu_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="cpu_worker")
44
 
45
+ # Limit concurrent active network tasks (downloads/uploads) to 5
46
+ network_semaphore = asyncio.Semaphore(5)
47
 
48
+
49
+ async def upload_to_uploadthing(client: httpx.AsyncClient, image_bytes: bytes, filename: str) -> str:
50
  url = f"{WEB_APP_URL}/api/worker/upload"
51
 
52
  files = {"file": (filename, image_bytes, "image/png")}
53
  headers = {"Authorization": f"Bearer {WORKER_SECRET}"} if WORKER_SECRET else {}
54
 
55
+ response = await client.post(url, files=files, headers=headers, timeout=60.0)
56
 
57
  if response.status_code != 200:
58
  logger.error(f"Worker Upload API error: {response.status_code} - {response.text}")
 
68
  return blob_url
69
 
70
 
71
+ async def process_job(job_data_str: str):
72
+ job_id = None
73
  try:
74
  job = json.loads(job_data_str)
75
  job_id = job["id"]
 
78
 
79
  logger.info(f"Processing job {job_id} from {source_url}")
80
 
81
+ async with httpx.AsyncClient() as client:
82
+ response = await client.get(source_url, timeout=30.0)
83
+ response.raise_for_status()
84
+
85
+ await r.hset(f"{PREFIX}:job_status:{job_id}", mapping={"status": "processing"})
86
+
87
+ # Offload CPU-bound ML and Pillow image tasks to the ThreadPoolExecutor
88
+ loop = asyncio.get_running_loop()
89
+ async with cpu_semaphore:
90
+ input_image = await loop.run_in_executor(
91
+ cpu_executor, Image.open, io.BytesIO(response.content)
92
+ )
93
+ output_image = await loop.run_in_executor(
94
+ cpu_executor, functools.partial(remove, session=session), input_image
95
+ )
96
+ img_byte_arr = io.BytesIO()
97
+ await loop.run_in_executor(
98
+ cpu_executor, output_image.save, img_byte_arr, "PNG"
99
+ )
100
+ img_bytes = img_byte_arr.getvalue()
101
+
102
+ name_parts = original_filename.rsplit(".", 1)
103
+ if len(name_parts) == 2:
104
+ base_name, ext = name_parts
105
+ filename = f"{base_name}-nobg.{ext}"
106
+ else:
107
+ filename = f"{original_filename}-nobg.png"
108
+
109
+ result_url = await upload_to_uploadthing(client, img_bytes, filename)
110
+
111
+ await r.hset(
112
+ f"{PREFIX}:job_status:{job_id}",
113
+ mapping={"status": "completed", "result_url": result_url},
114
+ )
115
+ await r.expire(f"{PREFIX}:job_status:{job_id}", 3600)
116
+
117
+ logger.info(f"Job {job_id} completed successfully. URL: {result_url}")
118
 
119
  except Exception as e:
120
  logger.error(f"Error processing job: {str(e)}")
121
+ if job_id:
122
+ try:
123
+ await r.hset(f"{PREFIX}:job_status:{job_id}", mapping={"status": "failed"})
124
+ await r.expire(f"{PREFIX}:job_status:{job_id}", 3600)
125
+ except Exception as redis_err:
126
+ logger.error(f"Failed to update failed status in Redis: {str(redis_err)}")
127
 
128
 
129
+ async def worker_loop():
130
  logger.info(f"Starting Redis worker loop... Listening on queue: {PREFIX}:job_queue")
131
  while True:
132
  try:
133
+ # Wait for permit before popping a job
134
+ await network_semaphore.acquire()
135
+
136
+ try:
137
+ result = await r.brpop(f"{PREFIX}:job_queue", timeout=5)
138
+ if result:
139
+ _, job_data_str = result
140
+
141
+ async def run_and_release():
142
+ try:
143
+ await process_job(job_data_str)
144
+ finally:
145
+ network_semaphore.release()
146
+
147
+ asyncio.create_task(run_and_release())
148
+ else:
149
+ network_semaphore.release()
150
+ await asyncio.sleep(0.1)
151
+ except Exception as inner_e:
152
+ network_semaphore.release()
153
+ raise inner_e
154
+
155
+ except asyncio.CancelledError:
156
+ logger.info("Worker loop cancelled.")
157
+ break
158
  except Exception as e:
159
  logger.error(f"Redis connection/worker loop error: {str(e)}")
160
+ await asyncio.sleep(5)
161
+
162
+
163
+ @asynccontextmanager
164
+ async def lifespan(app: FastAPI):
165
+ worker_task = asyncio.create_task(worker_loop())
166
+ yield
167
+ worker_task.cancel()
168
+ try:
169
+ await worker_task
170
+ except asyncio.CancelledError:
171
+ pass
172
+ cpu_executor.shutdown(wait=True)
173
 
174
 
175
+ app = FastAPI(title="NoBG Worker", lifespan=lifespan)
 
 
 
176
 
177
 
178
  @app.get("/")