Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
28 changes: 28 additions & 0 deletions .github/workflows/test-migrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ on:
- 'tests/web/test_user_oauth_actor_ownership.py'
- 'tests/shared/postgres_disposable.py'
- 'tests/web/services/checkpoint_anchor_shared.py'
- 'tests/web/api/test_mcp_server_edit_lock_postgresql.py'
- 'tests/web/api/test_custom_api_edit_lock_postgresql.py'
- 'src/xagent/web/api/mcp.py'
- 'src/xagent/web/api/custom_api.py'
- 'src/xagent/web/services/connector_team_scope.py'
- 'src/xagent/web/models/custom_api.py'
- 'src/xagent/web/models/mcp.py'
pull_request:
branches: [main]
# Required by the merge queue: without this the two required contexts below
Expand Down Expand Up @@ -138,6 +145,13 @@ jobs:
tests/web/test_user_oauth_actor_ownership.py
tests/shared/postgres_disposable.py
tests/web/services/checkpoint_anchor_shared.py
tests/web/api/test_mcp_server_edit_lock_postgresql.py
tests/web/api/test_custom_api_edit_lock_postgresql.py
src/xagent/web/api/mcp.py
src/xagent/web/api/custom_api.py
Comment thread
AlexLiu190625 marked this conversation as resolved.
src/xagent/web/services/connector_team_scope.py
src/xagent/web/models/custom_api.py
src/xagent/web/models/mcp.py
)

case "$EVENT_NAME" in
Expand Down Expand Up @@ -448,6 +462,20 @@ jobs:
env:
XAGENT_TEST_POSTGRES_URL: postgresql://xagent:xagent@localhost:5432/xagent_test

- name: Test MCP server edit row lock (Postgres-only)
if: needs.detect-migration-changes.outputs.should-test == 'true'
run: |
pytest tests/web/api/test_mcp_server_edit_lock_postgresql.py -m postgresql -q
env:
XAGENT_TEST_POSTGRES_URL: postgresql://xagent:xagent@localhost:5432/xagent_test

- name: Test Custom API edit row lock (Postgres-only)
if: needs.detect-migration-changes.outputs.should-test == 'true'
run: |
pytest tests/web/api/test_custom_api_edit_lock_postgresql.py -m postgresql -q
env:
XAGENT_TEST_POSTGRES_URL: postgresql://xagent:xagent@localhost:5432/xagent_test

migrations-summary:
name: Migrations Summary
runs-on: ubuntu-latest
Expand Down
168 changes: 150 additions & 18 deletions src/xagent/web/api/custom_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, cast

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
Expand Down Expand Up @@ -286,7 +286,7 @@ async def get_custom_api(


@custom_api_router.put("/{api_id}", response_model=CustomApiResponse)
async def update_custom_api(
def update_custom_api(
api_id: int,
api_data: CustomApiUpdate,
current_user: User = Depends(get_current_user),
Expand Down Expand Up @@ -315,9 +315,81 @@ async def update_custom_api(
detail="You do not have permission to edit this Custom API",
)

api = user_api.custom_api
# Which row a request writes decides which row it locks. Every field of
# ``CustomApiUpdate`` except ``is_active`` writes the shared
# ``CustomApi`` definition row; ``is_active`` writes this caller's own
# ``UserCustomApi`` link row and nothing else. Locking the definition
# row for a payload that never writes it made an activate/deactivate
# queue behind an unrelated edit of the same connector -- a wait that
# request has no write to justify, and one that surfaces as an error
# rather than a delay wherever a lock timeout is configured.
#
# ``model_fields_set`` decides this, not the values: an explicitly-null
# ``runtime_input_schema`` is written to the definition row below even
# though its value is ``None``, while an absent field is not written at
# all. The set is a superset of the writes below -- a payload carrying
# ``description=None`` is counted here and then skipped by the write at
# its own ``is not None`` guard -- so this takes the lock in a few
# cases that did not need it and skips it in none that do.
fields_set = api_data.model_fields_set
writes_definition_row = bool(fields_set - {"is_active"})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor; Blocking: no — same C7 root [prior]. I saw reply 3889610607 that model_fields_set fixes exact is_active-only updates and protects explicit-null runtime writes, while explicitly admitting some over-locking. That is correct but incomplete: for supported {"is_active": false, "description": null} or {"is_active": false, "name": <current>}, fields_set - {"is_active"} is nonempty, yet the guards at custom_api.py:394-427 skip the companion field and only UserCustomApi.is_active is written at :475-477. The request still waits on the shared row, causing avoidable lock latency and worker/connection fan-in. Classify effective writes (or normalize no-ops) before adding the parent lock, preserve locking for real/runtime definition writes, and add null/same-name boundary tests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed as described: a payload like {"is_active": false, "description": null} has fields_set - {"is_active"} non-empty (description is present), so it takes the lock at custom_api.py:335, then writes zero definition-row fields -- the description is not None guard at :404-405 skips the null, and is_active only ever writes UserCustomApi.is_active. Same for {"is_active": false, "name": <the current name>}: name is in fields_set, the lock is taken, and then if api_data.name and api_data.name != api.name at :394 is false so nothing is written. Both are real extra waits with no write behind them.

I looked at moving to a "what will actually be written" classification instead of "which fields are present," and I'd rather not, for two reasons.

First, it isn't a free win even on correctness: runtime_input_schema writes on an explicit null (:457-458, if "runtime_input_schema" in fields_set: mutable_api.runtime_input_schema = ..., unconditional on the value), so a value-based classifier has to special-case that field or it silently drops back to no-lock for a real write -- it's not one rule, it's the write logic re-derived a second time in a second place. Every time the write guards below change, this second copy has to change with them, and the failure direction if it doesn't is an unlocked write to the shared row -- a lost update -- which is worse than the extra wait it's meant to remove.

Second, the name-unchanged case can't be decided from the request body at all: the check is api_data.name != api.name, and api.name only exists once the definition row has been read. Deciding "is this actually a no-op" ahead of the lock would mean reading that row once to check, then reading it again (locked) to write -- an extra query added specifically to the path this suggestion is trying to make cheaper.

So I'm keeping the field-presence criterion as is. The extra locking it causes is confined to no-op companion payloads riding alongside is_active, and getting that wrong in the other direction is the more expensive mistake.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correcting one sentence in my earlier reply on this thread: I wrote that checking the definition's current name at the classifier "would mean reading that row once to check, then reading it again (locked) to write -- an extra query." That's wrong. By the time the classifier (custom_api.py:335) runs, the entry gate above it has already lazy-loaded user_api.custom_api (the not user_api.custom_api check at custom_api.py:306), which pulls the definition row's columns, including name, into memory on the same statement. A classifier keyed on user_api.custom_api.name would cost zero additional queries — it's already sitting in the session.

The conclusion doesn't change, but the reason does. The real problem with a value-based classifier isn't its cost — it's that the only name it could compare against is the one read at that earlier, unlocked point, and the write logic downstream compares against a different, later read instead (the fresh, locked-when-taken read at custom_api.py:366). A payload that matches the earlier read but not a concurrently-renamed later one would score as a no-op under a value-based classifier and skip the lock, while the write logic still fires off the later read — removing the lock removes what serializes that write against a concurrent commit landing in the same gap. That's a lost-update risk, not an extra-query one. The full argument and the two new boundary tests are on the other thread on this line, since it carries this round's specific ask.

Comment thread
AlexLiu190625 marked this conversation as resolved.

# A fresh single-table read of the definition row, on both paths. The
# read above comes through the personal link row's relationship and
# cannot itself address just this table; this is a separate statement,
# so a row deleted between the two yields None here (handled as the
# same 404) rather than surfacing as an unrelated error out of the
# write path or out of ``db.refresh`` below. ``populate_existing()``
# makes this statement's row the one the rest of this route reads and
# responds with: without it the already-identity-mapped instance the
# relationship loaded would be returned unrefreshed, and every field
# below would still be that earlier snapshot.
#
# ``FOR UPDATE`` is added only on the path that writes this row, so a
# request that writes it still waits for another request holding it.
# That clause is a PostgreSQL/MySQL row lock only: SQLAlchemy renders
# no locking clause at all on SQLite -- the statement it emits there is
# byte-for-byte the one it emits without this call -- so on a SQLite
# deployment the read-modify-write below is not serialized and two
# concurrent edits of one connector can still interleave. The
# single-statement conditional ``UPDATE``s elsewhere in this repository
# (services/task_interaction_staging.py,
# services/chat_history_service.py) are safe on SQLite without a lock
# because one statement is atomic there; that reasoning does not carry
# to this route, which reads the row, computes in Python, and writes it
# back. Closing the SQLite window needs the dual-dialect fence
# ``_lock_user_row_for_preferences_update`` (api/auth.py) and
# ``acquire_runtime_key_transition_fence`` (services/api_keys.py) use
# -- a no-op ``UPDATE`` that takes SQLite's writer lock -- and is left
# to a change of its own.
definition_query = (
db.query(CustomApi).filter(CustomApi.id == api_id).populate_existing()
)
if writes_definition_row:
definition_query = definition_query.with_for_update()
Comment thread
AlexLiu190625 marked this conversation as resolved.
current_api = definition_query.first()
if current_api is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Custom API not found",
)
api = current_api

# Read only after the statement above: rename_team_connector's "old"
# argument must be the name this transaction's own read established --
# under the lock, on the path that renames -- not whatever the
# relationship read further up saw. A concurrent committed rename in
# between would otherwise make this stale, and the rewrite below would
# look for a name that no longer exists anywhere, leaving the previous
# renamer's selectors dangling with no error.
old_name = str(api.name)

# The row's declared type from here on is loosened for mypy's sake: the
# column-typed attributes below (name, description, env, ...) are all
# mutated directly by this route, exactly as they were when this local
# came off the relationship instead of off the definition query above.
mutable_api = cast(Any, api)

# Check name uniqueness if name is changed
if api_data.name and api_data.name != api.name:
existing = db.query(CustomApi).filter(CustomApi.name == api_data.name).first()
Expand All @@ -326,33 +398,34 @@ async def update_custom_api(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Custom API with name '{api_data.name}' already exists",
)
api.name = api_data.name
mutable_api.name = api_data.name

# Update fields
if api_data.description is not None:
api.description = api_data.description
mutable_api.description = api_data.description
if api_data.url is not None:
api.url = api_data.url
mutable_api.url = api_data.url
if api_data.method is not None:
api.method = api_data.method
mutable_api.method = api_data.method
if api_data.headers is not None:
api.headers = api_data.headers
mutable_api.headers = api_data.headers
if api_data.body is not None:
api.body = api_data.body
mutable_api.body = api_data.body

# Process env variables
if api_data.env is not None:
existing_env = api.env if isinstance(api.env, dict) else {}
existing_env: Dict[str, str] = (
mutable_api.env if isinstance(api.env, dict) else {}
)
try:
processed_env = _process_env_vars(api_data.env, existing_env)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid environment variables: {exc}",
) from exc
api.env = processed_env
mutable_api.env = processed_env

fields_set = api_data.model_fields_set
runtime_input_schema = (
api_data.runtime_input_schema
if "runtime_input_schema" in fields_set
Expand All @@ -374,19 +447,19 @@ async def update_custom_api(
runtime_input_schema=runtime_input_schema,
runtime_bindings=runtime_bindings,
allow_delegated_authorization=allow_delegated_authorization,
static_headers=api.headers,
static_headers=mutable_api.headers,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid runtime configuration: {exc}",
) from exc
if "runtime_input_schema" in fields_set:
api.runtime_input_schema = runtime_input_schema
mutable_api.runtime_input_schema = runtime_input_schema
if "runtime_bindings" in fields_set:
api.runtime_bindings = runtime_bindings
mutable_api.runtime_bindings = runtime_bindings
if "allow_delegated_authorization" in fields_set:
api.allow_delegated_authorization = allow_delegated_authorization
mutable_api.allow_delegated_authorization = allow_delegated_authorization

from ..services.connector_team_scope import rename_team_connector

Expand All @@ -410,7 +483,7 @@ async def update_custom_api(


@custom_api_router.delete("/{api_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_custom_api(
def delete_custom_api(
api_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
Expand Down Expand Up @@ -438,7 +511,65 @@ async def delete_custom_api(
detail="You do not have permission to delete this Custom API",
)

api = user_api.custom_api
# One lock order across every row this pair of routes touches. An
# ``update_custom_api`` call that writes the definition row locks this
# same row first, calls ``rename_team_connector`` afterwards, and
# writes the ``UserCustomApi`` link row afterwards too; both branches below delete
# the link row first and the definition row second, inside one
# transaction. Taking the lock here -- before ``delete_team_connector``
# rather than after it -- puts both routes in one order on both sides of
# the hook boundary: definition row first, then the link row and
# whatever rows a connector team hook locks. With the lock after the
# hook instead, the two routes waited in opposite directions across that
# boundary and a concurrent edit/delete pair on the same connector could
# deadlock (PostgreSQL 40P01, surfacing to the caller as HTTP 500).
# ``populate_existing`` matches the PUT's own lock: the row this
# transaction holds is the one the deletion below acts on, not whatever
# the relationship read above happened to see. This is also a fresh
# statement, so a row deleted between the access read above and here
# still yields None (handled as the same 404) rather than reaching
# ``db.delete`` with nothing to delete.
#
# Two costs come with taking it here rather than last. The two 403
# refusals below read ``delete_team_connector``'s answer and so now
# happen after this statement: a request that is going to be refused
# does briefly hold this row, until the raised ``HTTPException``
# propagates out and the request's session is closed without
# committing. And the hook's own work now runs inside the lock, so this
# route holds the row for longer than it did with the lock last.
#
# What this statement orders is this route against ``update_custom_api``
# on the same connector. A PUT that writes the definition row cannot
# form a cycle with this route, whatever the hook locks, because both
# reach the hook with this row already held and so cannot be inside the
# hook at the same time. A PUT that writes only the caller's own
# ``UserCustomApi`` link row takes no lock on this row at all: it reads
# the definition row without locking it and waits only for the link
# row, so it has nothing this route waits for and cannot close a cycle
# either. A hook that additionally locks rows of its own in some other
# order relative to this repository's statements is outside what this
# statement arranges; only the installing application can order those.
#
# ``FOR UPDATE`` here is a PostgreSQL/MySQL row lock only: SQLAlchemy
# renders no locking clause at all on SQLite, so on a SQLite deployment
# this statement does not order this route against anything. Closing
# that window needs the dual-dialect fence
# ``acquire_runtime_key_transition_fence`` (services/api_keys.py) uses
# -- a no-op ``UPDATE`` that takes SQLite's writer lock -- and is left
# to a change of its own.
locked_api = (
db.query(CustomApi)
.filter(CustomApi.id == api_id)
.populate_existing()
.with_for_update()
Comment thread
AlexLiu190625 marked this conversation as resolved.
.first()
)
if locked_api is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Custom API not found",
)
api = locked_api

from ..services.connector_team_scope import delete_team_connector

Expand All @@ -455,6 +586,7 @@ async def delete_custom_api(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only a team admin can delete a team Custom API",
)

if team_delete.team_owned:
db.delete(user_api)
db.flush([user_api])
Expand Down
42 changes: 41 additions & 1 deletion src/xagent/web/api/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -3239,11 +3239,51 @@ def update_mcp_server(
)

user_mcp, server = result
old_name = str(server.name)
can_edit_global = _check_mcp_permission(
user_mcp, getattr(current_user, "is_admin", False), require="edit"
)

# A second, single-table lock on the definition row, taken before any
# tamper check or config build below reads or mutates it. The read
# above is a two-table join and cannot itself lock just this table;
# this is a fresh statement, so a row deleted between the two still
# yields None here (handled as the same 404) rather than surfacing
# as an unrelated error out of the write path below.
# ``populate_existing()`` makes the locked row the one the rest of
# this route reads: without it the already-identity-mapped instance
# from the join above would be returned unrefreshed, and every field
# below would still be the pre-lock snapshot.
#
# ``FOR UPDATE`` here is a PostgreSQL/MySQL row lock only:
# SQLAlchemy renders no locking clause at all on SQLite, so on a
# SQLite deployment the read-modify-write below is not serialized
# and two concurrent edits of one server can still interleave.
# Closing that window needs the dual-dialect fence
# ``acquire_runtime_key_transition_fence`` (services/api_keys.py)
# uses -- a no-op ``UPDATE`` that takes SQLite's writer lock -- and
# is left to a change of its own.
locked_server = (
db.query(MCPServer)
.filter(MCPServer.id == server_id)
.populate_existing()
.with_for_update()
Comment thread
AlexLiu190625 marked this conversation as resolved.
Outdated
Comment thread
AlexLiu190625 marked this conversation as resolved.
Outdated
.first()
)
if locked_server is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="MCP server not found"
)
server = locked_server

# Read only after the lock: rename_team_connector's "old" argument
# must be the name this transaction actually holds locked, not
# whatever was there at the pre-lock read above -- a concurrent
# committed rename in between would otherwise make this stale, and
# the rewrite below would then look for a name that no longer
# exists anywhere, leaving the previous renamer's selectors
# dangling with no error.
old_name = str(server.name)

# Non-owners may not touch the shared global config (env, command, etc.);
# they only get to set their own per-user env override below. Reject a
# tampered payload outright (defense-in-depth for direct/stale-UI calls)
Expand Down
Loading
Loading