Skip to content

Commit 3f69577

Browse files
fix: bulk-fetch task fields to fix N+1 and get_project_stats crash (#6)
1 parent 841d01f commit 3f69577

2 files changed

Lines changed: 310 additions & 367 deletions

File tree

src/clearml_mcp/clearml_mcp.py

Lines changed: 119 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""ClearML MCP Server implementation."""
22

3-
from typing import Any
3+
import re
4+
from typing import Any, cast
45

56
from clearml import Model, Task
67
from fastmcp import FastMCP
@@ -18,6 +19,77 @@ def initialize_clearml_connection() -> None:
1819
raise RuntimeError(f"Failed to initialize ClearML connection: {e!s}")
1920

2021

22+
# Fields fetched in the single bulk ``query_tasks`` call below. Requesting these
23+
# via ``additional_return_fields`` makes ClearML hydrate them server-side in one
24+
# paginated call (``tasks.get_all_ex`` with ``only_fields``), avoiding a per-task
25+
# ``Task.get_task`` round-trip (the N+1 problem fixed here, see issue #6).
26+
_TASK_FIELDS = ("id", "name", "status", "type", "comment", "created", "project", "tags")
27+
28+
29+
def _project_id_to_name(task_dicts: list[dict[str, Any]]) -> dict[str, str]:
30+
"""Build a project-id -> project-name map for the projects referenced by tasks.
31+
32+
``query_tasks`` returns a project *id* per task. We resolve names in a single
33+
``get_projects`` call rather than one lookup per task.
34+
"""
35+
project_ids = {d.get("project") for d in task_dicts if d.get("project")}
36+
if not project_ids:
37+
return {}
38+
return {
39+
proj.id: proj.name
40+
for proj in Task.get_projects()
41+
if getattr(proj, "id", None) in project_ids
42+
}
43+
44+
45+
def _query_task_dicts(
46+
project_name: str | None = None,
47+
task_name: str | None = None,
48+
tags: list[str] | None = None,
49+
status: str | None = None,
50+
any_pattern: str | None = None,
51+
any_fields: tuple[str, ...] = ("name", "comment", "tags"),
52+
) -> list[dict[str, Any]]:
53+
"""Fetch fully hydrated task records in a single bulk backend call.
54+
55+
``Task.query_tasks`` returns bare ID strings by default; with
56+
``additional_return_fields`` it returns a list of dicts with the requested
57+
fields fetched in one bulk call. ``task_name``, ``status`` and ``any_pattern``
58+
are all matched server-side so we never download every task just to filter
59+
client-side. ``any_pattern`` is a regex matched against any of ``any_fields``.
60+
"""
61+
task_filter: dict[str, Any] = {}
62+
if status:
63+
task_filter["status"] = [status]
64+
if any_pattern:
65+
task_filter["_any_"] = {"fields": list(any_fields), "pattern": any_pattern}
66+
# ``additional_return_fields`` guarantees a list of dicts (one per task).
67+
raw = cast(
68+
"list[dict[str, Any]]",
69+
Task.query_tasks(
70+
project_name=project_name,
71+
task_name=task_name,
72+
tags=tags,
73+
additional_return_fields=list(_TASK_FIELDS),
74+
task_filter=task_filter or None,
75+
),
76+
)
77+
project_names = _project_id_to_name(raw)
78+
return [
79+
{
80+
"id": d.get("id"),
81+
"name": d.get("name"),
82+
"status": d.get("status"),
83+
"type": d.get("type"),
84+
"comment": d.get("comment") or "",
85+
"created": str(d.get("created")) if d.get("created") is not None else None,
86+
"project": project_names.get(d.get("project"), d.get("project")),
87+
"tags": list(d.get("tags") or []),
88+
}
89+
for d in raw
90+
]
91+
92+
2193
@mcp.tool()
2294
async def get_task_info(task_id: str) -> dict[str, Any]:
2395
"""Get ClearML task details, parameters, and status."""
@@ -46,33 +118,18 @@ async def list_tasks(
46118
) -> list[dict[str, Any]]:
47119
"""List ClearML tasks with filters."""
48120
try:
49-
# Task.query_tasks returns task IDs (strings), not task objects
50-
task_ids = Task.query_tasks(
51-
project_name=project_name,
52-
task_filter={"status": [status]} if status else None,
53-
tags=tags,
54-
)
55-
56-
# Convert task IDs to full task objects
57-
tasks = []
58-
for task_id in task_ids:
59-
try:
60-
task = Task.get_task(task_id=task_id)
61-
tasks.append(
62-
{
63-
"id": task.id,
64-
"name": task.name,
65-
"status": task.status,
66-
"project": task.get_project_name(),
67-
"created": str(task.data.created),
68-
"tags": list(task.data.tags) if task.data.tags else [],
69-
}
70-
)
71-
except Exception as e:
72-
# If we can't get a specific task, include the error but continue
73-
tasks.append({"id": task_id, "error": f"Failed to get task details: {e!s}"})
74-
75-
return tasks
121+
tasks = _query_task_dicts(project_name=project_name, status=status, tags=tags)
122+
return [
123+
{
124+
"id": t["id"],
125+
"name": t["name"],
126+
"status": t["status"],
127+
"project": t["project"],
128+
"created": t["created"],
129+
"tags": t["tags"],
130+
}
131+
for t in tasks
132+
]
76133
except Exception as e:
77134
return [{"error": f"Failed to list tasks: {e!s}"}]
78135

@@ -254,30 +311,18 @@ async def find_experiment_in_project(
254311
) -> list[dict[str, Any]]:
255312
"""Find experiments in a specific project by name pattern."""
256313
try:
257-
# Get task IDs for the project
258-
task_ids = Task.query_tasks(project_name=project_name)
259-
260-
matching_experiments = []
261-
pattern_lower = experiment_pattern.lower()
262-
263-
for task_id in task_ids:
264-
try:
265-
task = Task.get_task(task_id=task_id)
266-
if pattern_lower in task.name.lower():
267-
matching_experiments.append(
268-
{
269-
"id": task.id,
270-
"name": task.name,
271-
"status": task.status,
272-
"project": task.get_project_name(),
273-
"created": str(task.data.created),
274-
}
275-
)
276-
except Exception:
277-
# Skip tasks we can't access - could be permissions or API issues
278-
pass
279-
280-
return matching_experiments
314+
# task_name matching happens server-side, so only matching tasks come back.
315+
tasks = _query_task_dicts(project_name=project_name, task_name=experiment_pattern)
316+
return [
317+
{
318+
"id": t["id"],
319+
"name": t["name"],
320+
"status": t["status"],
321+
"project": t["project"],
322+
"created": t["created"],
323+
}
324+
for t in tasks
325+
]
281326
except Exception as e:
282327
return [{"error": f"Failed to find experiments: {e!s}"}]
283328

@@ -302,18 +347,19 @@ async def list_projects() -> list[dict[str, Any]]:
302347
async def get_project_stats(project_name: str) -> dict[str, Any]:
303348
"""Get project statistics and task counts."""
304349
try:
305-
tasks = Task.query_tasks(project_name=project_name)
350+
tasks = _query_task_dicts(project_name=project_name)
306351

307-
status_counts = {}
352+
status_counts: dict[str, int] = {}
308353
for task in tasks:
309-
status = task.status
310-
status_counts[status] = status_counts.get(status, 0) + 1
354+
status = task["status"]
355+
if status:
356+
status_counts[status] = status_counts.get(status, 0) + 1
311357

312358
return {
313359
"project_name": project_name,
314360
"total_tasks": len(tasks),
315361
"status_breakdown": status_counts,
316-
"task_types": list(set(task.type for task in tasks if hasattr(task, "type"))),
362+
"task_types": sorted({task["type"] for task in tasks if task["type"]}),
317363
}
318364
except Exception as e:
319365
return {"error": f"Failed to get project stats: {e!s}"}
@@ -364,44 +410,22 @@ async def compare_tasks(task_ids: list[str], metrics: list[str] | None = None) -
364410
async def search_tasks(query: str, project_name: str | None = None) -> list[dict[str, Any]]:
365411
"""Search tasks by name, tags, or description."""
366412
try:
367-
# Task.query_tasks returns task IDs (strings), not task objects
368-
task_ids = Task.query_tasks(project_name=project_name)
369-
370-
matching_tasks = []
371-
query_lower = query.lower()
372-
373-
for task_id in task_ids:
374-
try:
375-
task = Task.get_task(task_id=task_id)
376-
377-
# Check if the task matches the search query
378-
task_name = task.name.lower()
379-
task_comment = getattr(task, "comment", "") or ""
380-
task_tags = list(task.data.tags) if task.data.tags else []
381-
382-
if (
383-
query_lower in task_name
384-
or (task_comment and query_lower in task_comment.lower())
385-
or any(query_lower in tag.lower() for tag in task_tags)
386-
):
387-
matching_tasks.append(
388-
{
389-
"id": task.id,
390-
"name": task.name,
391-
"status": task.status,
392-
"project": task.get_project_name(),
393-
"created": str(task.data.created),
394-
"tags": task_tags,
395-
"comment": task_comment,
396-
}
397-
)
398-
except Exception as e:
399-
# If we can't get a specific task, skip it but log the error
400-
matching_tasks.append(
401-
{"id": task_id, "error": f"Failed to get task details: {e!s}"}
402-
)
403-
404-
return matching_tasks
413+
# Match the query as a literal substring (case-insensitive) against
414+
# name/comment/tags server-side, so we never hydrate non-matching tasks.
415+
pattern = f"(?i){re.escape(query)}"
416+
tasks = _query_task_dicts(project_name=project_name, any_pattern=pattern)
417+
return [
418+
{
419+
"id": t["id"],
420+
"name": t["name"],
421+
"status": t["status"],
422+
"project": t["project"],
423+
"created": t["created"],
424+
"tags": t["tags"],
425+
"comment": t["comment"],
426+
}
427+
for t in tasks
428+
]
405429
except Exception as e:
406430
return [{"error": f"Failed to search tasks: {e!s}"}]
407431

0 commit comments

Comments
 (0)