כשאתה מנהל סוכנות רכב באזרבייג'ן ומייבא רכבי salvage מארצות הברית, בעיות התוכנה שלך נראות אחרת מאלו של סטארט-אפ בסיליקון ואלי. אתה לא מבצע אופטימיזציה עבור מיליון משתמשים בו-זמנית. אתה מבצע אופטימיזציה עבור בהירות, זמינות (uptime), והיכולת לתקן דברים בעצמך בחצות תוך תיאום עם בית מכירות פומביות שמרחק השעות ממך הוא שתים-עשרה. זה בדיוק המצב שבו מצאתי את עצמי כשבניתי את AutoMakler. הפלטפורמה מטפלת בהכל – החל מ-scraping של מכירות פומביות בשידור חי וחיפושי Carfax, ועד להערכות משלוח ועיבוד תשלומים. זוהי מערכת production אמיתית המשרתת לקוחות אמיתיים, והיא רצה על מה שרוב המפתחים היו מכנים "סטאק משעמם באופן אגרסיבי".

הסטאק שאף אחד לא רוצה לשווק

אין React. אין Vue. אין Redis, אין Celery, ואין שרת WebSocket. ה-backend הוא FastAPI עם Python נקי. מסד הנתונים הוא PostgreSQL. ה-frontend הוא HTML המרונדר בשרת באמצעות תבניות Jinja2, Bootstrap וקורט של vanilla JavaScript. לצורך scraping, אני משתמש ב-Playwright. הכל רץ כתהליך Python יחיד שמגיש HTML ישירות.

אין שלב build. אין תיקיות node_modules לבדיקה, אין transpilers להגדרה, ואין צורך לרדוף אחרי התחלפות מהירה של frontend frameworks. כשאני פורס (deploy), אני מעביר קבצי Python ותבניות, ולא מנהל pipeline של bundlers. הפשטות הזו היא לא פשרה. היא כל העניין.

איך לתור משימות ללא Message Broker

scraping של מכירה פומבית חיה של רכב לא יכול לקרות באופן סינכרוני. scraping בודד יכול לקחת מספר שניות בזמן ש-Playwright טוען את הדף, מריץ JavaScript ושואב את הנתונים. חסימת המשתמש בזמן שזה קורה היא לא אופציה. ה-"playbook" הסטנדרטי אומר להתקין Redis, להגדיר Celery ולהקים worker pool. אני דילגתי על כל זה.

במקום זאת, AutoMakler משתמש ב-Postgres כ-job queue משלו. כאשר משתמש מפעיל scraping, האפליקציה כותבת שורה חדשה בטבלת tasks עם סטטוס pending. משימת asyncio ברקע אוספת את השורה הזו ומפעילה את ה-browser scrape. בינתיים, הדפדפן מבצע polling ל-endpoint קל משקל כל שלוש שניות כדי לבדוק את הסטטוס. כשהשורה מתעדכנת ל-completed, הדף מתרענן ומציג את התוצאות.

התבנית הזו עובדת כי מרווח ה-polling קצר מספיק כדי להרגיש תגובתי, אך ארוך מספיק כדי לא להעמיס על השרת. שלוש שניות הן נצח עבור מחשב, וכמעט לא מורגשות עבור בן אדם שמחכה לאתר מכירות פומביות חיצוני. מסד הנתונים מטפל ב-concurrency באופן טבעי, ומכיוון שהמשימות הן פשוט שורות ב-Postgres, אני יכול לבדוק את התור באמצעות שאילתת SQL פשוטה במקום לחפור בלוגים של Celery או במפתחות של Redis.

איך לשמור על השרת חי ללא Worker Pool

אוטומציה של דפדפן היא זללנית זיכרון. אם תפעיל יותר מדי מופעים (instances) של Playwright בבת אחת, השרת שלך יקרוס. הפתרון המקובל הוא managed worker pool עם מגבלות concurrency, שלעיתים קרובות נשען על שילוב של Redis ו-Celery. אני משתמש בשורה אחת של Python: asyncio.Semaphore.

ה-semaphore מגביל כמה מופעי דפדפן יכולים לרוץ בו-זמנית. כשמתקבלת בקשת scraping חדשה, היא או שתופסת מקום (slot) באופן מיידי או שתמתין עד שיתפנה מקום. כל זה קורה בתוך אותו תהליך. אין orchestrator חיצוני שעלול להיכשל, אין תהליך worker שעלול למות בשקט, ואין תשתית נוספת לניטור. צריכת הזיכרון שלי נשארת צפויה, והקוד שמגן על השרת נמצא ממש ליד הקוד שמשתמש בו, ולא מוחבא בתוך deployment manifest.

ניתוב כספים באמצעות כתובת Callback URL אחת

עיבוד תשלומים הציב מגבלה שלא יכולתי לשנות. שער התשלומים (payment gateway) שלי מאפשר בדיוק כתובת callback URL אחת לכל חשבון סוחר (merchant account), אך הייתי צריך לעבד עסקאות עבור שני פרויקטים נפרדים דרך אותו חשבון יחיד. בניית פרופיל סוחר שני הייתה משמעותה עמלות נוספות, עמידה ברגולציה (compliance) נוספת ובירוקרטיה נוספת שסוכנות רכב קטנה פשוט לא יכולה להרשות לעצמה.

הפתרון היה לקודד את שם הפרויקט ישירות לתוך מחרוזת ה-order ID לפני שליחת הלקוח לשער התשלומים. כשה-callback מגיע לשרת שלי, AutoMakler מפענח את ה-ID הזה, מזהה לאיזה פרויקט שייך התשלום, ומנתב את ההודעה ל-handler הפנימי הנכון. הלוגיקה הקיימת נשארה ללא שינוי. זהו עיצוב אדיטיבי (additive design): לא כתבתי מחדש את תהליך התשלום, רק גרמתי למזהה לשאת מעט יותר הקשר (context). זה סוג של "hack" שנראה מובן מאליו בדיעבד, אך חוסך שעות של אקרובטיקה ארכיטקטונית.

צ'אט שעובד ללא WebSockets

צ'אט תמיכה בלקוחות הוא בדרך כלל המקום שבו מהנדסים נכנעים ומוסיפים WebSockets. הייתי זקוק להודעות בתוך האפליקציה (in-app messaging), אך גם רציתי לשמור על טביעת רגל קטנה של התשתית. לכן, השתמשתי מחדש באותה אסטרטגיית polling שמניעה את סריקות המכירות הפומביות (auction scrapes).

ההודעות נשמרות ב-Postgres. כשמשתמש שולח הודעה, היא נכתבת לטבלה. הלקוח מבצע polling לעדכונים, וממשק המשתמש (UI) משקף הודעות חדשות ואישורי קריאה (read receipts) כמעט בזמן אמת. כדי לשמור על מהירות זו גם ככל שטבלת השיחות גדלה, הוספתי partial index ב-Postgres שמכסה רק הודעות שלא נקראו בשיחות פעילות. מסד הנתונים אינו מבזבז מחזורי עיבוד (cycles) על סריקת היסטוריה ישנה, ומתכנן השאילתות (query planner) יכול לספק את רוב שליפות הצ'אט באמצעות סריקת טווח אינדקס צרה (tight index range scan).

עבור צ'אט תמיכה שבו השהיה (latency) של כמה שניות היא מקובלת, זה מספיק בהחלט. המשתמשים מקבלים את המשוב שהם צריכים, ומעולם לא נאלצתי לדבג חיבור WebSocket תקוע (stale) או לנהל שרת socket נפרד.

החסרונות הכנים

הארכיטקטורה הזו כרוכה בפשרות (trade-offs) אמיתיות, והעמדת פנים אחרת תהיה לא כנה. Polling הוא "רועש" (chatty). בכל שלוש שניות, כל לקוח פעיל פונה לשרת. רוחב הפס ועומס השאילתות גבוהים יותר ממה שחיבור socket קבוע (persistent) היה דורש. אם תהליך ה-Python עובר ריסטארט, כל משימת רקע פעילה (in-flight) מתה מיד כי אין עובד חיצוני (external worker) שיקח אותה בחזרה. אני מקבל זאת מכיוון שהמשימות קטנות ועלות הניסיון מחדש (retry) נמוכה. סריקת דפדפן שנכשלה יכולה פשוט להיות מופעלת מחדש על ידי המשתמש.

יש גם תקרה לגישה הזו. אם AutoMakler יצטרך אי פעם לשרת אלפי סריקות בו-זמניות, מודל התהליך הבודד (single-process model) עם polling יתקשה. אך זה לא העסק שאני עוסק בו. אני זקוק לאמינות עבור עשרות משתמשים בו-זמנית, לא