Cron scripts that loop profile_ids break at 50+ accounts — duplicate launches, orphaned browsers, no retry semantics. A queue + worker pool with profile leasing fixes that. This recipe uses Redis; swap for SQS, RabbitMQ, or PostgreSQL SKIP LOCKED with the same job schema.
Architecture
API / dashboard → LPUSH mlx:jobs Worker pool → BRPOP → lease profile → start → CDP job → stop → webhook CMDB → profile_id, tier, lease_until, ban_status
Job schema (JSON)
{
"job_id": "uuid",
"profile_id": "mlx-profile-uuid",
"task": "sync_orders",
"payload": {"shop_id": "acme_vn"},
"attempt": 1,
"max_attempts": 3,
"callback_url": "https://ops.example/hooks/mlx"
}
Profile lease (prevent double launch)
import redis
import json
import time
r = redis.Redis(decode_responses=True)
LEASE_TTL = 900 # 15 min
def try_lease(profile_id: str, worker_id: str) -> bool:
key = f"mlx:lease:{profile_id}"
return r.set(key, worker_id, nx=True, ex=LEASE_TTL)
def release_lease(profile_id: str, worker_id: str) -> None:
key = f"mlx:lease:{profile_id}"
if r.get(key) == worker_id:
r.delete(key)
Worker loop (asyncio + httpx)
import asyncio
import httpx
MLX = "https://api.multilogin.com"
TOKEN = "Bearer ..."
SEM = asyncio.Semaphore(4)
async def run_job(job: dict):
profile_id = job["profile_id"]
if not try_lease(profile_id, WORKER_ID):
raise RetryLater("profile leased")
async with SEM:
async with httpx.AsyncClient(timeout=90) as client:
start = await client.post(
f"{MLX}/profile/start",
json={"profile_id": profile_id, "headless": True},
headers={"Authorization": TOKEN},
)
start.raise_for_status()
cdp = start.json().get("cdp_url")
try:
await execute_task(cdp, job) # your Playwright fn
finally:
await client.post(
f"{MLX}/profile/stop",
json={"profile_id": profile_id},
headers={"Authorization": TOKEN},
)
release_lease(profile_id, WORKER_ID)
async def worker_loop():
while True:
_, raw = r.brpop("mlx:jobs", timeout=5)
if not raw:
continue
job = json.loads(raw)
try:
await run_job(job)
post_webhook(job["callback_url"], {"job_id": job["job_id"], "status": "ok"})
except RetryLater:
r.lpush("mlx:jobs", raw) # re-queue with delay in prod
except Exception as e:
if job["attempt"] < job["max_attempts"]:
job["attempt"] += 1
r.lpush("mlx:jobs", json.dumps(job))
post_webhook(job["callback_url"], {"job_id": job["job_id"], "status": "fail", "error": str(e)})
Webhook callback (idempotent)
def post_webhook(url: str, body: dict):
if not url:
return
httpx.post(url, json=body, timeout=10, headers={"X-MLX-Signature": sign(body)})
Receivers should dedupe on job_id. Sign payloads with HMAC secret shared with your ops dashboard. Full receiver: webhook receiver recipe.
Operational rules
- One profile per job — never parallel CDP on same UUID
- Lease TTL > max job duration — extend lease in long jobs
- Dead letter queue — after
max_attempts, move tomlx:dlq— handler: DLQ recipe - Rate limit — global semaphore matches Multilogin plan cap
- Metrics — job latency, start failures, ban rate per profile. Traces: OpenTelemetry recipe
Related
OpenTelemetry traces
Profile pool manager
DLQ handler
Webhook receiver
Production recipe
Scaling multi-account
Debug runbook
Linux CI
Code hub
Disclosure: MLX-MMO affiliated with Multilogin.