Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/whats-new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ What's New
- **fix: הדגל נכנס למפתח הקאש של** ``/files``\ **, אחרת השינוי לא היה נראה.** העמוד שומר את ה-HTML המרונדר, והמפתח נבנה מפרמטרי החיפוש בלבד. בלי הדגל, משתמש שמדליק את המתג היה מקבל את ה-HTML של המצב הקודם עד שה-TTL פוקע, ומסיק שהפיצ'ר שבור. הדגל נוסף כתחילית למפתח כך שגם ענף ה-fallback מבדיל בין המצבים. ביטול קאש נשקל ונפסל: אין היום שום קוד שמבטל את ``web:files:user:*``, ומפתח שמתאר את התוכן אינו זקוק לפעולה שיכולה להיכשל בשקט.
- **fix: ההעדפה נפתרת פעם אחת לכל בקשה.** ``/files`` בונה את מפתח הקאש לפני הרינדור וה-context processor מזין את התבנית — שתי קריאות נפרדות למסד באותה בקשה. שינוי העדפה שנוחת ביניהן היה גורם ל-HTML של מצב אחד להישמר תחת התגית של המצב השני, וכל מי שמבקש אחר כך את התצוגה ההיא היה מקבל את ההפך עד פקיעת ה-TTL. התוצאה נשמרת עכשיו על ``g``, ההיקף היחיד שמובטח שהוא בדיוק בקשה אחת.
- **fix: המאזין של המתג מסומן על האלמנט, כי הבלוק** ``extra_js`` **של עמוד ההגדרות מרונדר פעמיים.** ``settings.html`` מצהיר אותו בתוך ``block content``, ולכן Jinja מרנדר אותו גם שם וגם ב-``base.html``. נמדד בדפדפן: לחיצה אחת, אירוע ``change`` אחד, ושתי בקשות ל-``/api/ui_prefs`` משתי שורות שונות באותו עמוד. הכפילות קיימת גם ב-``persistentToggle``, ב-``fontMinus`` וב-``themeSelect``, ולא תוקנה כאן — היא נוגעת בכל העמוד ומצריכה שינוי מבני נפרד.
- **fix (חיפוש): כשל במסלול החיפוש המהיר נרשם ללוג במקום להיבלע.** שלושה ``except`` שקטים ב-``_safe_search`` — הראשון נופל מ-``$text`` ל-``$regex`` על ``code``, השני משם לפייפליין הישן, והשלישי מחזיר "לא נמצאו תוצאות". אף אחד מהם לא כתב שורה אחת, ולכן כשהמסלול המהיר נשבר המערכת עברה בשקט לשאילתה **אחרת**: סריקה מלאה בלי אינדקס, שנמדדה כשאילתה **האיטית ביותר בכל הלוח** (3,730ms). ההערה שהייתה שם ניחשה "למשל אין אינדקס טקסט" — הניחוש נבדק ונפסל: ``search_text_idx`` קיים, ואותה שאילתת ``$text`` בדיוק רצה מול הקלאסטר ומחזירה תוצאות. ⚠️ ולא לבלבל בין שני פולבאקים שונים: להגיע לפונקציה הזו זה מסלול **תקין** שקורה כשמנוע החיפוש החזיר אפס תוצאות; ה-``except`` הוא משהו אחר — השאילתה עצמה נזרקה. כשל שלישי מחזיר "לא נמצאו תוצאות", כלומר חיפוש שבור נראה בדיוק כמו חיפוש שלא מצא — וזה ההבדל היחיד שחשוב למשתמש.
- **fix (פרופיילר): ``code`` נוסף לרשימת השדות שנשמרים עם ערכים אמיתיים.** בלעדיו שאילתת החיפוש נדחתה ב-``unknown_field:code``, וניתוח שלה רץ על ``{"code": {"$regex": "<value>"}}`` — regex שאינו מתאים כמעט לכלום, ולכן ה-explain דיווח "מהיר, אפס מסמכים נסרקו" **על השאילתה האיטית ביותר במערכת**. הערך שנשמר הוא דפוס החיפוש שהוקלד ולא תוכן הקובץ. ובאותו מעבר תוקנה ההערה מעל הרשימה: היא טענה שהרשימה נגזרה מ"השדות של ``code_snippets``" (נמדדו 32 שדות באוסף מול 19 ברשימה), בעוד הכלל האמיתי הוא *השדות שמסננים לפיהם בשאילתות שמשויכות למשתמש* — מה שגם מסביר למה שדות ה-worker אינם שייכים לשם: הם נדחים כ-``owner_missing`` לפני שהרשימה נבדקת בכלל.
- **fix (פרופיילר): שינוי מסנן ה-collection באמצע דפדוף כבר אינו מעלים שורות.** ל-``<select>`` של הסינון **לא היה שום מאזין שינוי** — עד שנוסף העימוד זה היה פגם חוויה בלבד (בחירה לא עשתה כלום עד לחיצה על "רענן"), אבל עם קורסור זה הפך לבאג נכונות: בחירת collection ואז "טען עוד" שלחה את המסנן החדש עם הקורסור הישן, שנטבע עבור אוכלוסייה אחרת. במיון יורד לפי זמן, שורות של ה-collection החדש שזמנן מאוחר מנקודת הקורסור לא היו מופיעות לעולם. שתי שכבות: ה-select מרענן ומאפס את הקורסור, **והקורסור עצמו נושא עכשיו את מסנני האוכלוסייה** ונדחה ב-400 כשהם משתנים — בדיוק כמו שהוא כבר נושא את שדה המיון והכיוון. הכלל אחד: קורסור תקף רק לשאילתה שהוא נטבע עבורה.
- **docs (פרופיילר): הובהר ש-``/api/profiler/slow-queries`` מוגש בידי שני שירותים שונים.** הוובאפ (סשן אדמין) מממש עימוד, מיון, ``total`` ו-``next_cursor``; הראוט של הבוט (``X-Profiler-Token``) מממש רק ``limit``/``collection``/``min_time``/``hours``, ומתעלם בשקט מכל פרמטר אחר. דוגמת ה-``curl`` בעמוד השתמשה בטוקן בזמן שהטקסט תיאר את הוובאפ — כלומר מי שהעתיק אותה קיבל בדיוק את ההתעלמות השקטה שהעמוד מבטיח שלא תקרה. בנוסף: הפסקה על האינדקסים הבהירה שהם משרתים את הראוט של הבוט ולא את הדשבורד, וההנמקה על ``collection_timestamp`` מפרידה עכשיו בין "לא מספק את המיון" לבין "כן היה מצמצם סריקה" — שני דברים שהיו מעורבבים.
- **fix (פרופיילר): ``min_time`` חזר לעבוד.** הפרמטר מתועד, והראוט של הבוט עדיין משתמש בו — אבל המעבר ל-``get_slow_queries_page`` הפיל אותו בשקט: הראוט המשיך לקבל אותו, להתעלם, ולהחזיר **200 עם שורות לא מסוננות**. תשובה שנראית תקינה ואינה נכונה גרועה משגיאה, כי אין בה שום סימן שמשהו לא בסדר. הוא מצטרף עכשיו לפילטר החלון ולא לפילטר הדף — כלומר הוא נספר גם ב-``total``, בדיוק כמו ``collection``, אחרת "מוצגות 2 מתוך 4" היה מדווח על אוכלוסייה שכולה 2. ערך שאינו מספר סופי מוחזר כ-400 ולא מדולג: ``{"$gte": NaN}`` אינו מתאים לאף מסמך במונגו, כלומר טבלה ריקה בלי הסבר.
Expand Down
55 changes: 51 additions & 4 deletions services/query_profiler_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,51 @@ def _env_int(name: str, default: int) -> int:
#: המפתח שמזהה את בעל השאילתה.
RAW_QUERY_OWNER_KEY = "user_id"

#: שדות שמותר להם להופיע בשאילתה שנשמרת עם ערכים. נגזר מהשדות של
#: ``code_snippets`` בפרודקשן וממה שנרשם ב-``slow_queries_log``.
#: שדות שמותר להם להופיע בשאילתה שנשמרת עם ערכים.
#:
#: **הכלל אינו "השדות של האוסף".** הוא: *השדות שמסננים לפיהם בשאילתות
#: שמשויכות למשתמש*. ההבדל אינו סמנטי — הוא נגזר מסדר הבדיקות ב-
#: ``_decide_raw_query``: שער הבעלות רץ **לפני** בדיקת השדות, ולכן הרשימה
#: רואה רק שאילתות שכבר הצהירו על משתמש מורשה יחיד. שדות ה-worker
#: (``needs_embedding``, ``contentHash``, ``chunkerVersion`` ומשפחת
#: ``embedding*``) נדחים כ-``owner_missing`` הרבה קודם ולכן אינם שייכים לכאן,
#: ו-``snippetEmbedding`` לעולם לא — הוא וקטור, לא מסנן.
#:
#: ההערה הקודמת כאן טענה שהרשימה נגזרה מ"השדות של ``code_snippets``
#: בפרודקשן", וזה לא היה מדויק: נמדדו 32 שדות באוסף מול 19 ברשימה, ו-``code``
#: — שנמצא ב-400 מתוך 400 מסמכים שנדגמו — נשמט. הערה שמתארת כלל שגוי היא מה
#: שמייצר את הפער הבא, ולכן היא תוקנה לכלל האמיתי.
#:
#: ⚠️ **מגבלה ידועה:** הרשימה גלובלית, אבל היא מתארת את ``code_snippets``.
#: שאילתה משויכת-משתמש על אוסף אחר (``large_files``, ‏``markdown_images``,
#: ‏``note_reminders``, ‏``users`` — כולם מופיעים ב-``slow_queries_log``) תיפסל
#: על השדות הלגיטימיים של עצמה. זה סעיף נפרד ולא תוקן כאן.
RAW_QUERY_ALLOWED_FIELDS: FrozenSet[str] = frozenset({
"user_id", "_id", "is_active", "file_name", "programming_language", "tags",
"description", "version", "created_at", "updated_at", "deleted_at",
"deleted_expires_at", "file_size", "lines_count", "is_favorite", "favorited_at",
"is_pinned", "pinned_at", "pin_order",
# ``code`` — ראו ``RAW_QUERY_FIELD_OPERATORS``: הוא מותר **רק** כדפוס
# חיפוש, ולא כערך שוויון.
"code",
})

#: הגבלת אופרטורים לשדה מסוים, מעל ``RAW_QUERY_ALLOWED_OPERATORS``.
#:
#: **למה זה נחוץ דווקא ל-``code``.** הוא נוסף לרשימה כי בשאילתת החיפוש הוא
#: נושא את **דפוס החיפוש שהוקלד** — כלומר קלט של המשתמש עצמו, קצר. אבל
#: רשימת האופרטורים הכללית מתירה לכל שדה גם ``$eq``/``$in``/``$all``, ותנאי
#: שוויון על ``code`` הוא דבר אחר לגמרי: הוא נושא את **תוכן הקובץ**. שאילתה
#: כזו סבירה לגמרי בעתיד (בדיקת כפילות תוכן, למשל), והיא הייתה שומרת עד
#: ``PROFILER_UNREDACTED_MAX_BYTES`` של קוד מקור ב-``slow_queries_log`` לשבוע,
#: מציגה אותו בדשבורד, ומכניסה אותו לטקסט "העתק דוח ל-AI".
#:
#: ההערה הקודמת כאן טענה ש-``code`` "תמיד בצד השמאלי של ``$regex``" — וזו
#: הייתה טענה על הקוראים של היום, לא אילוץ. כאן היא הופכת לאילוץ נאכף.
RAW_QUERY_FIELD_OPERATORS: Dict[str, FrozenSet[str]] = {
"code": frozenset({"$regex", "$options"}),
}

RAW_QUERY_LOGICAL_OPERATORS: FrozenSet[str] = frozenset({"$and", "$or", "$nor"})
RAW_QUERY_TEXT_OPTIONS: FrozenSet[str] = frozenset(
{"$search", "$language", "$caseSensitive", "$diacriticSensitive"}
Expand Down Expand Up @@ -692,13 +728,24 @@ def _asserted_owners(condition: Any) -> FrozenSet[str]:
return frozenset(owners)


def _check_field_value(value: Any) -> None:
def _check_field_value(value: Any, field: Optional[str] = None) -> None:
"""אופרטורים על ערך שדה. ``field`` נמסר כשיש לו הגבלה משלו.

שדה שמופיע ב-``RAW_QUERY_FIELD_OPERATORS`` חייב להגיע כמילון של
אופרטורים מתוך הרשימה הצרה שלו — ולא כערך שוויון ישיר. ראו שם למה.
"""
restricted = RAW_QUERY_FIELD_OPERATORS.get(field) if field else None
if not isinstance(value, dict):
if restricted is not None:
# ``{"code": "<תוכן הקובץ>"}`` — בדיוק מה שהרשימה הצרה מונעת.
raise _RawQueryWithheld(f"unsupported_field_value:{field}")
return
for op, inner in value.items():
op = str(op)
if op not in RAW_QUERY_ALLOWED_OPERATORS:
raise _RawQueryWithheld(f"unknown_operator:{op}")
if restricted is not None and op not in restricted:
raise _RawQueryWithheld(f"unsupported_field_operator:{field}{op}")
if op == "$elemMatch" and isinstance(inner, dict):
if all(str(k).startswith("$") for k in inner):
_check_field_value(inner)
Expand Down Expand Up @@ -730,7 +777,7 @@ def _check_condition(condition: Any) -> None:
else:
if key not in RAW_QUERY_ALLOWED_FIELDS:
raise _RawQueryWithheld(f"unknown_field:{key}")
_check_field_value(value)
_check_field_value(value, key)


def _stage_entries(pipeline: Any) -> List[Tuple[str, Any]]:
Expand Down
Loading
Loading