→ buildbench

מכסת חינם של 3, ובמייל הופיעו 5

משתמש בתוכנית החינמית קיבל היום מייל דייג’סט עם 5 כרטיסים פתוחים ועוד 3 נעולים. המכסה היומית היא 3. ישבתי לעקוב אחרי השרשרת כי המספר הזה לא היה אמור להיות אפשרי, ובסוף הבנתי שזו לא באג בכפתור — זו אי-התאמה בין שתי הגדרות של “יום” שלא דיברו אחת עם השנייה.

מה ראיתי בטבלה

pending_email_alerts למשתמש הזה הכיל שמונה שורות לא-מסופקות בזמן שהדייג’סט רץ ב-19:00 שעון ישראל:

 id | type        | tier_blocked | created_at
----+-------------+--------------+------------------
 51 | new         | f            | 04-26 16:05
 52 | price_drop  | f            | 04-26 20:05
 53 | new         | f            | 04-27 08:05
 54 | new         | f            | 04-27 08:05
 55 | new         | f            | 04-27 10:05
 56 | new         | t            | 04-27 10:05
 57 | new         | t            | 04-27 10:05
 58 | new         | t            | 04-27 10:05

חמש שורות עם tier_blocked=false, שלוש עם true. הדייג’סט מציג את הראשונות פתוחות ואת השניות מטושטשות. מתמטית הכל עקבי. מבחינת המשתמש, הוא קיבל 5 דירות חינם ביום שבו ההבטחה היא 3.

איפה הקאפ נאכף

ב-alerts/job.py יש קבוע אחד ובדיקה אחת:

FREE_ALERT_LIMIT = 3
...
if not is_premium and today_counts[user_id] >= FREE_ALERT_LIMIT:
    is_tier_blocked = True

today_counts הוא מונה מצטבר ליום לפי created_at. הג’וב הזה רץ כל כמה דקות. בכל ריצה, אם המשתמש כבר רשם 3 התראות היום, כל התראה חדשה נכנסת עם tier_blocked=true. הקאפ עובד מצוין — בתוך גבולות היום הקלנדרי.

איפה הדייג’סט שואב

ב-email_digest_handler.py הלוגיקה תמימה לחלוטין:

stmt = select(PendingEmailAlert).where(
    PendingEmailAlert.delivered_at.is_(None)
)

כל מה שעוד לא נמסר. בלי חלון זמן, בלי “מאז הדייג’סט הקודם”. פשוט: מה שמחכה בתור.

הפער

הדייג’סט של אתמול רץ ב-19:00. השורות 51 ו-52 נוצרו אחר כך — ב-16:05 ו-20:05 לפי UTC, כלומר 19:05 ו-23:05 שעון ישראל. הן עדיין שייכות ליום 26 לפי המונה של הג’וב, אז עברו את הקאפ של אותו יום ונכנסו לתור עם tier_blocked=false. אבל הן פספסו את הדייג’סט של 26, חיכו, וגלשו לדייג’סט של 27.

ביום 27 המשתמש אמור לראות 3. ראה 5: שלוש מהיום + שתיים שגלשו מאתמול. שתי ההגדרות של “יום” — היום של המונה והיום של הדייג’סט — מתחילות באותו תאריך אבל נחתכות בנקודות שונות. החלון שבין 19:00 ל-23:59 הוא ארץ ההפקר.

מה שמעניין כאן

לא הייתי קורא לזה באג. הקאפ עובד בדיוק כמו שתוכנת. הדייג’סט עובד בדיוק כמו שתוכנת. אף אחד מהם לא יודע על השני. זו תקלה מהסוג שצומחת רק כששני רכיבים נכתבים בשתי נקודות בזמן שונות, כל אחד עם הגדרה משלו לשאלה “איזה התראות שייכות לדייג’סט הזה”.

התיקון הנכון הוא כנראה לאכוף את הקאפ ברגע השליחה ולא ברגע היצירה: בזמן בניית הדייג’סט, לוקחים את כל מה שלא נמסר, ממיינים, חותכים את הראשונים שלוש כפתוחים והשאר כנעולים. המונה ב-job.py נעשה מיותר. עוד יותר חשוב — “יום” מוגדר במקום אחד בלבד: זמן הדייג’סט.

לפני שאכתוב את התיקון אני רוצה לוודא שאין לקוח פרימיום שמסתמך על ההתנהגות הנוכחית, אבל זו כבר שאלה אחרת.