Skip to content

perf: eager load EmailTeam relationship in Tool model to eliminate N+1 queries#1957

Merged
crivetimihai merged 1 commit intomainfrom
performance-eager-email-team-load
Jan 7, 2026
Merged

perf: eager load EmailTeam relationship in Tool model to eliminate N+1 queries#1957
crivetimihai merged 1 commit intomainfrom
performance-eager-email-team-load

Conversation

@jonpspri
Copy link
Copy Markdown
Collaborator

@jonpspri jonpspri commented Jan 7, 2026

Summary

  • Add email_team relationship to the Tool model with lazy="joined" for automatic eager loading
  • Add team property on Tool that returns the team name from the eagerly-loaded relationship
  • Remove manual team name fetching code (_get_team_name method) and explicit JOIN queries in tool_service.py

This simplifies the codebase while improving query efficiency by leveraging SQLAlchemy's relationship eager loading instead of manual queries.

Closes #1964

Copilot AI review requested due to automatic review settings January 7, 2026 18:10
@jonpspri jonpspri force-pushed the performance-eager-email-team-load branch from 09c252d to 2acb317 Compare January 7, 2026 18:13
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the Tool model to use SQLAlchemy relationship-based eager loading for the EmailTeam association, replacing manual team name fetching code throughout the service layer. The changes aim to eliminate N+1 query problems by leveraging SQLAlchemy's built-in eager loading capabilities.

Key changes:

  • Added email_team relationship on Tool model with lazy="joined" for automatic eager loading of active teams
  • Introduced read-only team property that returns team name from the eagerly-loaded relationship
  • Removed manual team name queries and the _get_team_name method from tool_service.py

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
mcpgateway/db.py Adds email_team relationship with eager loading and team property to Tool model
mcpgateway/services/tool_service.py Removes manual team fetching logic and simplifies query code to rely on relationship loading
tests/unit/mcpgateway/services/test_tool_service.py Updates test mocks to align with new structure and removes now-unused imports

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -199,6 +200,7 @@ def mock_tool(mock_gateway):
tool.custom_name_slug = "test-tool"
tool.display_name = None
tool.tags = []
tool.team = None
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The team attribute is being set here, but in the new model definition (mcpgateway/db.py:2823-2830), team is now a read-only property without a setter. This will raise an AttributeError at runtime when trying to set this value. The mock should set tool.team_id and tool.email_team instead, or the Tool model needs to be updated with a setter for the team property.

Suggested change
tool.team = None
tool.team_id = None
tool.email_team = None

Copilot uses AI. Check for mistakes.
mcpgateway/db.py Outdated
"EmailTeam",
primaryjoin="and_(Tool.team_id == EmailTeam.id, EmailTeam.is_active == True)",
foreign_keys=[team_id],
lazy="joined",
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The relationship should include viewonly=True parameter since it uses a filtered primaryjoin condition (EmailTeam.is_active == True). Without this, SQLAlchemy may attempt to use this relationship for write operations, which could lead to unexpected behavior. The relationship should be defined as read-only since it includes filtering logic.

Suggested change
lazy="joined",
lazy="joined",
viewonly=True,

Copilot uses AI. Check for mistakes.
mcpgateway/db.py Outdated
Comment on lines +2815 to +2820
# Relationship for eager loading team names (only active teams)
email_team: Mapped[Optional["EmailTeam"]] = relationship(
"EmailTeam",
primaryjoin="and_(Tool.team_id == EmailTeam.id, EmailTeam.is_active == True)",
foreign_keys=[team_id],
lazy="joined",
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using lazy="joined" causes SQLAlchemy to automatically add a LEFT OUTER JOIN to email_teams on every Tool query, even when the team name is not needed. This can negatively impact performance, especially for high-traffic operations like tool invocations where only the team_id is required for logging. Consider using lazy="selectin" or lazy="select" instead, which would only load the team when explicitly accessed. Alternatively, use explicit joinedload() options in queries that actually need the team name (like in list_tools).

Suggested change
# Relationship for eager loading team names (only active teams)
email_team: Mapped[Optional["EmailTeam"]] = relationship(
"EmailTeam",
primaryjoin="and_(Tool.team_id == EmailTeam.id, EmailTeam.is_active == True)",
foreign_keys=[team_id],
lazy="joined",
# Relationship for loading team names (only active teams)
email_team: Mapped[Optional["EmailTeam"]] = relationship(
"EmailTeam",
primaryjoin="and_(Tool.team_id == EmailTeam.id, EmailTeam.is_active == True)",
foreign_keys=[team_id],
lazy="selectin",

Copilot uses AI. Check for mistakes.
@jonpspri jonpspri force-pushed the performance-eager-email-team-load branch from 2acb317 to e6f1033 Compare January 7, 2026 19:54
@crivetimihai crivetimihai self-assigned this Jan 7, 2026
…1 queries

Add email_team relationship with lazy="joined" to Tool model, replacing
manual team name lookups with a single JOIN query. This eliminates N+1
query patterns when fetching tools with their team names.

Changes:
- Add email_team relationship to Tool class with lazy="joined"
- Add primaryjoin condition to filter only active teams (is_active == True)
- Add team property to Tool class for convenient team name access
- Document the team property in Tool class docstring
- Remove _get_team_name helper method from tool_service.py
- Remove manual team_map batch loading in list_tools
- Remove manual outerjoin/add_columns in list_server_tools and list_tools_for_user
- Update tests to use new query pattern (.scalars().all())
- Fix flake8 issues in test file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Springer <jonpspri@gmail.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
@crivetimihai crivetimihai force-pushed the performance-eager-email-team-load branch from e6f1033 to 1e7d003 Compare January 7, 2026 21:00
@crivetimihai
Copy link
Copy Markdown
Member

Review and Fixes Applied

Issues Found and Fixed

  1. Critical Bug Fixed: The original PR added a read-only team property to the Tool model, but admin.py still tried to set t.team = team_map.get(...), which would cause an AttributeError at runtime. Fixed by removing the redundant team fetching code.

  2. Performance Optimization: Changed from lazy="joined" to default lazy loading based on review feedback. The original lazy="joined" would add a JOIN to EmailTeam for every Tool query, including hot paths like invoke_tool() where team names aren't needed. This could regress performance compared to the prior approach.

  3. Selective Eager Loading: Added explicit joinedload(DbTool.email_team) only in list/admin queries where team names are actually displayed:

    • tool_service.py:list_tools()
    • tool_service.py:list_server_tools()
    • tool_service.py:list_tools_cursor_based_with_rbac()
    • admin.py:admin_tools_partial_html()
  4. Test Coverage: Added test_list_server_tools_includes_team_name to guard against regressions if the loader strategy is changed in the future.

Final Design

  • Tool model has email_team relationship with default lazy loading (only loads when accessed)
  • List endpoints use explicit joinedload(DbTool.email_team) for efficient single-query loading
  • Hot paths (like invoke_tool) don't trigger the team loading at all - no overhead added
  • team property provides convenient access to the team name from the relationship

Test Results

  • All 264 relevant tests pass
  • All 79 performance tests pass
  • E2E admin tool tests pass

@crivetimihai crivetimihai merged commit ad6b09c into main Jan 7, 2026
52 checks passed
@crivetimihai crivetimihai deleted the performance-eager-email-team-load branch January 7, 2026 21:12
Copy link
Copy Markdown
Member

@crivetimihai crivetimihai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased and refactored

crivetimihai pushed a commit that referenced this pull request Jan 9, 2026
…N+1 queries (#1957)

Add email_team relationship and team property to Server model, replacing
the _get_team_name helper method. Use joinedload for list operations to
fetch team names in a single query instead of per-server lookups.

Signed-off-by: Jonathan Springer <jps@s390x.com>
crivetimihai added a commit that referenced this pull request Jan 9, 2026
* perf: eager load EmailTeam relationship in Server model to eliminate N+1 queries (#1957)

Add email_team relationship and team property to Server model, replacing
the _get_team_name helper method. Use joinedload for list operations to
fetch team names in a single query instead of per-server lookups.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(#1962): resolve N+1 query fixes breaking tests

- Fix convert_server_to_read to explicitly build server dict from
  attributes instead of using __dict__.copy(), which can return empty
  dict with SQLAlchemy's scalars().all() eager loading pattern

- Add missing joinedload(DbServer.email_team) to get_server and
  update_server methods for consistent team name loading

- Update test mocks to accept new options parameter for db.get() calls

- Update mock_server fixture with explicit None values for optional
  tracking fields to prevent MagicMock auto-creation

Closes #1962

Signed-off-by: Jonathan Springer <jps@s390x.com>

* perf(#1962): add eager loading to toggle_server_status

Add selectinload/joinedload options to db.get() call in
toggle_server_status to prevent N+1 queries when accessing
server relationships (tools, resources, prompts, a2a_agents,
email_team).

This completes the N+1 fix for issue #1962 which listed
toggle_server_status as an affected function with medium impact.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* fix(#1962): fix team property assignment and export N+1 query

- admin.py: Remove manual team assignment loop that would fail since
  Server.team is now a read-only @Property. Add joinedload for
  email_team relationship instead.

- export_service.py: Add selectinload for tools relationship in
  _export_selected_servers to prevent N+1 queries when accessing
  db_server.tools in a loop.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
kcostell06 pushed a commit to kcostell06/mcp-context-forge that referenced this pull request Feb 24, 2026
…1 queries (IBM#1957)

Add email_team relationship with lazy="joined" to Tool model, replacing
manual team name lookups with a single JOIN query. This eliminates N+1
query patterns when fetching tools with their team names.

Changes:
- Add email_team relationship to Tool class with lazy="joined"
- Add primaryjoin condition to filter only active teams (is_active == True)
- Add team property to Tool class for convenient team name access
- Document the team property in Tool class docstring
- Remove _get_team_name helper method from tool_service.py
- Remove manual team_map batch loading in list_tools
- Remove manual outerjoin/add_columns in list_server_tools and list_tools_for_user
- Update tests to use new query pattern (.scalars().all())
- Fix flake8 issues in test file


Signed-off-by: Jonathan Springer <jonpspri@gmail.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
kcostell06 pushed a commit to kcostell06/mcp-context-forge that referenced this pull request Feb 24, 2026
* perf: eager load EmailTeam relationship in Server model to eliminate N+1 queries (IBM#1957)

Add email_team relationship and team property to Server model, replacing
the _get_team_name helper method. Use joinedload for list operations to
fetch team names in a single query instead of per-server lookups.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(IBM#1962): resolve N+1 query fixes breaking tests

- Fix convert_server_to_read to explicitly build server dict from
  attributes instead of using __dict__.copy(), which can return empty
  dict with SQLAlchemy's scalars().all() eager loading pattern

- Add missing joinedload(DbServer.email_team) to get_server and
  update_server methods for consistent team name loading

- Update test mocks to accept new options parameter for db.get() calls

- Update mock_server fixture with explicit None values for optional
  tracking fields to prevent MagicMock auto-creation

Closes IBM#1962

Signed-off-by: Jonathan Springer <jps@s390x.com>

* perf(IBM#1962): add eager loading to toggle_server_status

Add selectinload/joinedload options to db.get() call in
toggle_server_status to prevent N+1 queries when accessing
server relationships (tools, resources, prompts, a2a_agents,
email_team).

This completes the N+1 fix for issue IBM#1962 which listed
toggle_server_status as an affected function with medium impact.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* fix(IBM#1962): fix team property assignment and export N+1 query

- admin.py: Remove manual team assignment loop that would fail since
  Server.team is now a read-only @Property. Add joinedload for
  email_team relationship instead.

- export_service.py: Add selectinload for tools relationship in
  _export_selected_servers to prevent N+1 queries when accessing
  db_server.tools in a loop.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[PERFORMANCE]: Fix N+1 queries for team name lookups in tool_service

3 participants