وقتی در آذربایجان یک کارگزاری خودرو را اداره می‌کنید و خودروهای اسقاطی (salvage) را از ایالات متحده وارد می‌کنید، مشکلات نرم‌افزاری شما با مشکلات یک استارتاپ در سیلیکون‌ولی متفاوت است. شما برای میلیون‌ها کاربر همزمان بهینه‌سازی نمی‌کنید. شما برای وضوح، پایداری (uptime) و توانایی تعمیر خودتان در نیمه‌شب، در حالی که با یک مزایده‌خانه در دوازده منطقه زمانی دورتر هماهنگ هستید، بهینه‌سازی می‌کنید. این دقیقاً همان وضعیتی بود که هنگام ساخت AutoMakler در آن قرار داشتم. این پلتفرم همه چیز را مدیریت می‌کند؛ از استخراج داده‌ها (scraping) از مزایده‌های زنده و جستجوی Carfax گرفته تا تخمین زمان تحویل و پردازش پرداخت. این یک سیستم عملیاتی واقعی است که به مشتریان واقعی خدمات می‌دهد و روی چیزی اجرا می‌شود که اکثر توسعه‌دهندگان آن را یک "استک به‌شدت خسته‌کننده" می‌نامند.

استکی که هیچ‌کس نمی‌خواهد آن را تبلیغ کند

خبری از React نیست. نه Vue، نه Redis، نه Celery و نه سرور WebSocket. بک‌اند از FastAPI با Python ساده استفاده می‌کند. پایگاه داده PostgreSQL است. فرانت‌اند، HTML است که در سمت سرور با استفاده از قالب‌های Jinja2، Bootstrap و مقدار کمی vanilla JavaScript رندر می‌شود. برای scraping از Playwright استفاده می‌کنم. همه چیز به عنوان یک پروسه واحد Python اجرا می‌شود که مستقیماً HTML را سرو می‌کند.

هیچ مرحله‌ی build وجود ندارد. هیچ پوشه node_modules برای بررسی، هیچ ترنسپایلری برای پیکربندی و هیچ تغییرات مداوم در فریم‌ورک‌های فرانت‌اند برای دنبال کردن وجود ندارد. وقتی مستقر (deploy) می‌کنم، فایل‌های Python و قالب‌ها را جابه‌جا می‌کنم، نه اینکه یک خط لوله (pipeline) از باندلرها را مدیریت کنم. این سادگی یک سازش نیست؛ بلکه هدف اصلی است.

چگونه بدون یک Message Broker، کارها را در صف قرار دهیم

استخراج داده از یک مزایده خودروی زنده نمی‌تواند به صورت همزمان (synchronously) انجام شود. یک عملیات scrape ممکن است چندین ثانیه طول بکشد، زیرا Playwright صفحه را بارگذاری می‌کند، JavaScript را اجرا می‌کند و داده‌ها را استخراج می‌کند. مسدود کردن کاربر در حین انجام این کار، گزینه قابل قبولی نیست. دستورالعمل استاندارد می‌گوید Redis را نصب کنید، Celery را پیکربندی کنید و یک مجموعه از workerها را راه‌اندازی کنید. من همه این‌ها را نادیده گرفتم.

در عوض، AutoMakler از خودِ Postgres به عنوان صف وظایف (job queue) استفاده می‌کند. وقتی کاربر یک scrape را فعال می‌کند، اپلیکیشن یک ردیف جدید با وضعیت pending در جدول tasks می‌نویسد. یک task پس‌زمینه asyncio آن ردیف را برمی‌دارد و scrape مرورگر را شروع می‌کند. در همین حین، مرورگر هر سه ثانیه یک‌بار یک endpoint سبک را برای بررسی وضعیت فراخوانی (poll) می‌کند. وقتی وضعیت ردیف به completed تغییر کرد، صفحه رفرش شده و نتایج را نمایش می‌دهد.

این الگو به این دلیل کار می‌کند که فاصله زمانی polling به اندازه‌ای کوتاه است که پاسخگو به نظر برسد، اما به اندازه‌ای طولانی هست که از فشار بیش از حد به سرور جلوگیری کند. سه ثانیه برای یک کامپیوتر یک ابدیت است و برای انسانی که منتظر یک سایت مزایده خارجی است، به سختی قابل تشخیص است. پایگاه داده به طور بومی (natively) همزمانی (concurrency) را مدیریت می‌کند و چون وظایف فقط ردیف‌هایی در Postgres هستند، می‌توانم به جای جستجو در لاگ‌های Celery یا کلیدهای Redis، صف را با یک کوئری ساده SQL بررسی کنم.

زنده نگه داشتن سرور بدون یک Worker Pool

اتوماسیون مرورگر تشنه‌ی حافظه است. اگر همزمان نمونه‌های زیادی از Playwright را اجرا کنید، سرور شما از کار می‌افتد. راه حل متداول، استفاده از یک worker pool مدیریت‌شده با محدودیت‌های همزمانی است که اغلب توسط همان ترکیب Redis و Celery پشتیبانی می‌شود. من از یک خط کد Python استفاده می‌کنم: یک asyncio.Semaphore.

این semaphore تعداد نمونه‌های همزمان مرورگر را محدود می‌کند. وقتی یک درخواست scrape جدید می‌رسد، یا بلافاصله یک جایگاه (slot) اشغال می‌کند یا منتظر می‌ماند تا یکی آزاد شود. تمام این‌ها در همان پروسه اتفاق می‌افتد. هیچ orchestrator خارجی برای شکست خوردن وجود ندارد، هیچ پروسه worker برای از کار افتادن بی‌صدا وجود ندارد و هیچ زیرساخت اضافی برای نظارت وجود ندارد. مصرف حافظه من قابل پیش‌بینی می‌ماند و کدی که از سرور محافظت می‌کند، درست در کنار کدی است که از آن استفاده می‌کند، نه اینکه در یک فایل manifest استقرار پنهان شده باشد.

مسیریابی پول با تنها یک Callback URL

پردازش پرداخت محدودیتی را ایجاد کرد که نمی‌توانستم تغییر دهم. درگاه پرداخت من دقیقاً اجازه یک callback URL را برای هر حساب فروشنده می‌دهد، اما من نیاز داشتم تراکنش‌های دو پروژه مجزا را از طریق همان یک حساب پردازش کنم. ساختن یک پروفایل فروشنده دوم به معنای هزینه‌های اضافی، رعایت قوانین (compliance) بیشتر و کاغذبازی‌های اضافی بود که یک کارگزاری کوچک وقت انجام آن‌ها را ندارد.

راه حل این بود که نام پروژه را مستقیماً قبل از ارسال مشتری به درگاه، در رشته‌ی order ID کدگذاری (encode) کنم. وقتی callback به سرور من می‌رسد، AutoMakler آن ID را رمزگشایی (decode) می‌کند، تشخیص می‌دهد که پرداخت متعلق به کدام پروژه است و اعلان را به هندلر داخلی صحیح هدایت می‌کند. منطق موجود بدون تغییر باقی ماند. این یک طراحی افزایشی (additive design) است: من جریان پرداخت را بازنویسی نکردم، فقط باعث شدم شناسه (identifier) حاوی مقدار کمی اطلاعات بیشتر باشد. این از آن دست ترفندهایی (hack) است که در نگاه به گذشته بدیهی به نظر می‌رسد، اما ساعت‌ها از پیچیدگی‌های معماری جلوگیری می‌کند.

چت‌هایی که بدون WebSockets کار می‌کنند

Customer support chat is usually where engineers cave and add WebSockets. I needed in-app messaging, but I also needed to keep the infrastructure footprint tiny. So I reused the same polling strategy that powers the auction scrapes.

Messages are stored in Postgres. When a user sends a message, it writes to the table. The client polls for updates, and the UI reflects new messages and read receipts in near real-time. To keep this fast even as the conversation table grows, I added a Postgres partial index that only covers unread messages for active conversations. The database does not waste cycles scanning old history, and the query planner can satisfy most chat lookups with a tight index range scan.

For a support chat where a few seconds of latency is acceptable, this is perfectly adequate. The users get the feedback they need, and I never had to debug a stale WebSocket connection or manage a separate socket server.

The Honest Downsides

This architecture makes real trade-offs, and pretending otherwise would be dishonest. Polling is chatty. Every three seconds, every active client hits the server. The bandwidth and query load are higher than a persistent socket connection would demand. If the Python process restarts, any in-flight background task dies immediately because there is no external worker to pick it back up. I accept this because the tasks are small and the cost of a retry is low. A failed browser scrape can simply be re-triggered by the user.

There is also a ceiling to this approach. If AutoMakler ever needs to serve thousands of simultaneous scrapes, the single-process model with polling will strain. But that is not the business I am in. I need reliability for dozens of concurrent users, not