Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/whats-new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ What's New

2026-09-20
----------
- **chore (MCP): ארבעה פריטים קטנים מסקירת #3429 (#3433).** שורת הקיבולת של מאגר הקריאות קוראת את ``ThreadPoolExecutor._max_workers`` הפרטי דרך ``_installed_width``, וכשהמאפיין ייעלם היא אומרת "unreadable" לצד הרוחב שהתבקש עם WARNING, במקום שהשירות לא יעלה בגלל שורת לוג (SUGG-004); ‏``scripts/measure_md_parse_cost.py`` מודד צפיפות דרך ``md_parser.token_count`` הציבורית — על המופע שהכלי מריץ — במקום לקרוא ל-``_build_parser`` הפרטית ולבנות פרסר לכל קובץ (SUGG-010); שני טסטים חדשים מקבעים שקובץ ``memory.max`` שקיים אבל ריק או לא-מספרי נופל ל-cgroup v1 (SUGG-014); ואסרשן שלא יכול היה ליפול הוחלף בטסט שמצמיד ``os.cpu_count`` ל-16 כדי שה-fallback ייבדק מול 20 ולא מול עצמו (SUGG-001).
- **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`.
- **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`.
- **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`.
Expand Down
42 changes: 37 additions & 5 deletions mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,28 @@ def _read_pool_size(memory_limit: int | None) -> _ReadPoolSizing:
return _ReadPoolSizing(int(allowed), "memory", f"sized from memory: {arithmetic}")


def _installed_width(pool: ThreadPoolExecutor) -> int | None:
"""The width the executor actually has — or ``None`` when this Python no longer exposes it.

``ThreadPoolExecutor._max_workers`` is private to CPython, and it is read
on purpose (``state-record-without-state-change``: the capacity line
describes the pool that exists, not the one that was requested). Private
means it can go away. When it does, the answer here is ``None`` — not the
requested width, which would be exactly the echo the line exists to avoid
— and the callers print that they do not know, with a warning, instead of
failing the startup of the service over a log line (#3433, SUGG-004).
"""
return getattr(pool, "_max_workers", None)


def _width_label(pool: ThreadPoolExecutor, sizing: _ReadPoolSizing) -> str:
"""What the capacity line prints for the read pool: the installed width, or an honest "unreadable"."""
width = _installed_width(pool)
if width is not None:
return str(width)
return f"{sizing.workers} (requested; installed width unreadable)"


def _log_dispatch_capacity(
read_pool: ThreadPoolExecutor, memory_display: str, sizing: _ReadPoolSizing
) -> None:
Expand All @@ -821,18 +843,28 @@ def _log_dispatch_capacity(
number the pool is sized from.

Every value is computed before the call rather than inside it, so a level
guard or a deleted line takes the line and nothing else with it.
guard or a deleted line takes the line and nothing else with it. And the
private attribute is read through :func:`_installed_width`: should it
vanish, the line says "unreadable" beside the requested width and a
warning names the reason — a log line never fails the startup.
"""
detected = os.cpu_count() or 1
try:
usable = len(os.sched_getaffinity(0))
except (AttributeError, OSError):
usable = detected
quota = _cpu_budget()
if _installed_width(read_pool) is None:
logger.warning(
"mcp read pool width is not readable on this Python "
"(ThreadPoolExecutor._max_workers is gone); the capacity line reports "
"the requested width %d instead of the installed one",
sizing.workers,
)
logger.info(
"mcp dispatch capacity: read pool %d threads (%s; os.cpu_count=%d, "
"mcp dispatch capacity: read pool %s threads (%s; os.cpu_count=%d, "
"usable=%d), write pool 1 thread, cpu quota %s, memory limit %s",
read_pool._max_workers,
_width_label(read_pool, sizing),
sizing.detail,
detected,
usable,
Expand Down Expand Up @@ -904,9 +936,9 @@ async def _lifespan_with_read_pool(scope_app: Any):
# path nobody reports is the one that stays. The capacity line says
# the size; this says that it was not chosen.
logger.warning(
"mcp read pool fell back to %d threads: memory limit %s — reads are "
"mcp read pool fell back to %s threads: memory limit %s — reads are "
"narrower than the plan allows until the cgroup limit is readable",
pool._max_workers,
_width_label(pool, sizing),
memory_display,
)
async with original(scope_app) as state:
Expand Down
2 changes: 1 addition & 1 deletion scripts/measure_md_parse_cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def density(text: str, parser: str = "md") -> float:
if parser == "md":
from services import md_parser

return len(md_parser._build_parser().parse(text)) * 1024 / size
return md_parser.token_count(text) * 1024 / size
from services import rst_parser

return len(rst_parser.parse_document(text).sections) * 1024 / size
Expand Down
19 changes: 19 additions & 0 deletions services/md_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,25 @@ def _build_parser() -> MarkdownIt:
_MD = _build_parser()


def token_count(text: str) -> int:
"""מספר הטוקנים שהפרסר של הכלי מייצר לטקסט — מדד צפיפות, לא מפה.

קיים בשביל ``scripts/measure_md_parse_cost.py``, שמדרג מסמכים לפי
צפיפות טוקני-בלוק ל-KB, וקרא עד #3433 ל-``_build_parser`` הפרטית —
תלות סמויה בפנימי של המודול, ובנייה של פרסר חדש לכל קובץ. הפונקציה
הזאת היא התלות בגלוי: על ``_MD``, המופע שהכלי מריץ, בלי ``env`` ולכן
בלי תקרה (``_ceiling_rule`` מקבל ``None`` ומחזיר ``False``), ובלי
טוקני inline (``disable("inline")``) — כלומר בדיוק מה שהפרסור האמיתי
סופר.

**ומחוץ ל-``__all__`` בכוונה:** הרשימה שם היא החוזה של "שני פארסרים
בני-החלפה", וטסט מקבע את ההפרש בינה לבין זו של ``rst_parser``. מדד
למדידה אינו חלק מהחוזה הזה ואינו קיים ל-RST, ולכן הוא ציבורי (בלי קו
תחתון, מתועד) אך אינו מיוצא.
"""
return len(_MD.parse(text))


def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> Document:
"""בונה ``Document`` מטקסט Markdown.

Expand Down
Loading
Loading