fix(mcp): optimistic-lock concurrent MCP server PATCHes - #14005
Conversation
Follow-up to langflow-ai#13976. A concurrent merge PATCH to the same MCP server could still lose a field: two PATCHes read the same row and the last commit wins, dropping the other's change. Guard the merge-PATCH write with the existing version column (UPDATE ... WHERE id=? AND version=?); on a 0-row result, re-read, re-merge, and retry. A full replace stays last-writer-wins (unconditional update) so it never contends and cannot exhaust the retry budget. Adds a regression test running concurrent distinct-field PATCHes that asserts all survive.
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Walkthrough
ChangesMCP server concurrency handling
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 8 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (8 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/backend/tests/unit/api/v2/test_mcp_db_store.py (1)
334-334: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
@pytest.mark.asynciodecorators are redundant.This repo configures pytest-asyncio with
asyncio_mode = "auto", so async tests are auto-detected and these decorators can be dropped. If the surrounding tests in this file already carry the decorator, keep them consistent (remove file-wide in a follow-up rather than partially).Based on learnings: "pytest-asyncio is configured with asyncio_mode = 'auto' ... you do not need to decorate test functions or classes with pytest.mark.asyncio."
Also applies to: 364-364
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/backend/tests/unit/api/v2/test_mcp_db_store.py` at line 334, Remove the redundant `@pytest.mark.asyncio` decorators from the affected tests in this file, including both referenced locations, while preserving consistency with surrounding tests; if decorators are widespread, leave them unchanged for a separate file-wide cleanup.Source: Learnings
src/backend/base/langflow/api/v2/mcp.py (1)
426-484: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueRetry budget scales with concurrent same-server writers.
The version guard makes each conflicting writer retry roughly once per competing writer on the same
(user_id, name). With_MAX_UPSERT_RETRIES = 12, more than ~12 concurrent merge PATCHes to the same server can exhaust the budget and surface a spurious409even though no genuine deadlock exists. For the current test load (8 writers) this is safe. If this endpoint can see higher same-key fan-out, consider a larger/adaptive budget with jittered backoff, or documenting the practical concurrency ceiling.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/backend/base/langflow/api/v2/mcp.py` around lines 426 - 484, Update the retry handling around the upsert loop in the MCP server persistence logic to avoid spurious 409 responses under higher same-key concurrency. Increase or adapt the budget beyond the fixed _MAX_UPSERT_RETRIES value, add jittered backoff between version-conflict retries, and document the practical concurrency limit if the budget remains bounded; preserve the existing behavior for create races and successful updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/backend/base/langflow/api/v2/mcp.py`:
- Around line 466-473: Replace the 500 status with 404 or 409 when the merge
PATCH re-read in the concurrent-delete path finds no MCPServer, updating the
HTTPException in the MCP server merge logic. Also align the delete early-path
and check_existing handling to use appropriate 404/409 statuses instead of 500
for not-found or already-existing resources.
---
Nitpick comments:
In `@src/backend/base/langflow/api/v2/mcp.py`:
- Around line 426-484: Update the retry handling around the upsert loop in the
MCP server persistence logic to avoid spurious 409 responses under higher
same-key concurrency. Increase or adapt the budget beyond the fixed
_MAX_UPSERT_RETRIES value, add jittered backoff between version-conflict
retries, and document the practical concurrency limit if the budget remains
bounded; preserve the existing behavior for create races and successful updates.
In `@src/backend/tests/unit/api/v2/test_mcp_db_store.py`:
- Line 334: Remove the redundant `@pytest.mark.asyncio` decorators from the
affected tests in this file, including both referenced locations, while
preserving consistency with surrounding tests; if decorators are widespread,
leave them unchanged for a separate file-wide cleanup.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: c86725af-dd3b-45a6-8032-25724ae2952f
📒 Files selected for processing (2)
src/backend/base/langflow/api/v2/mcp.pysrc/backend/tests/unit/api/v2/test_mcp_db_store.py
Per review on langflow-ai#14005: a concurrently-deleted server during a merge PATCH, a missing server on delete, and a duplicate on create are client outcomes, not server faults. Return 404 (not found) and 409 (conflict) so they don't trip 5xx error handling. Applied consistently across update_server.
erichare
left a comment
There was a problem hiding this comment.
Thanks so much @thesaadmirza . some small changes
Requested changes
-
[P1] Keep versions monotonic across full replacements —
mcp.py:476The full-replace path increments from a potentially stale ORM value, allowing a version to be reused. A later guarded PATCH can then overwrite the newer replacement using stale config. Use a DB-side increment such as
version=MCPServer.version + 1and add a mixed replace/PATCH regression test. -
[P2] Re-raise non-duplicate integrity failures —
mcp.py:439Every
IntegrityErroris treated as a duplicate-create race. If no winning row exists, it retries twelve times and returns a misleading concurrency409. After refetching, re-raise whenexisting is None; only retry when a conflicting row exists. -
[P2] Make the PATCH/delete test deterministic —
test_mcp_db_store.py:385The probabilistic loop may never exercise the intended race, and the assertion accepts any
HTTPException, including 500. Use a synchronization barrier and assert that DELETE succeeds and PATCH returns exactly 404.
Per review of langflow-ai#14005: (1) full replace bumps the version DB-side (version = MCPServer.version + 1) so it stays monotonic even off a stale ORM read, instead of reusing a version a concurrent PATCH consumed; (2) the create-race handler re-raises when no winning row exists after refetch, so a genuine IntegrityError surfaces instead of retrying to a misleading 409; (3) replaced the probabilistic PATCH-vs-DELETE test with a deterministic one asserting the PATCH returns exactly 404, and added a regression test that injects a concurrent version advance between the replace's read and write to prove monotonicity.
|
Thanks @erichare, these all made sense. Fixed in P1 (version reuse on full replace). The replace now bumps the version in the UPDATE itself: P2 (non-duplicate integrity errors). After the refetch it re-raises when there's no winning row, so only the real duplicate-name race retries. A genuine IntegrityError now surfaces instead of looping into a 409. P2 (delete test). Dropped the probabilistic loop. It now deletes the row right between the PATCH's read and its guarded write, then asserts the PATCH returns 404 and the row is gone. |
erichare
left a comment
There was a problem hiding this comment.
Hi @thesaadmirza , do you mind rebasing this onto release-1.12.0? ill then do a final review and merge it in! Thank you!
Done, thanks @erichare! Updated it onto release-1.12.0 and it's mergeable with no conflicts. Ready for your final review whenever you have a moment. |
|
Thanks so much @thesaadmirza , appreciate the contribution! |
The merge-PATCH path already turns a row deleted between its read and its guarded UPDATE into a clean 404. The full-replace path never checked rowcount: the same race made its unguarded UPDATE match nothing, the loop broke, and the request returned None with the write silently lost. Check rowcount after the replace UPDATE; on a miss, re-read the row and loop. A still-missing row takes the create path on the next iteration, so the replace converges to a re-create (fresh row, version 1) instead of a silent no-op. Covered by a race-injection test in the same style as test_patch_losing_to_concurrent_delete_returns_404.
erichare
left a comment
There was a problem hiding this comment.
LGTM Thanks @thesaadmirza
d021b3e
Follow-up to #13976. In review, @erichare flagged one narrow lost-update case left in the DB-backed store: when two PATCHes hit the same server at once, both read the same row, each merges its change onto that snapshot, and the last commit wins, so the other PATCH's field is dropped.
update_servernow guards a merge PATCH with the row version:UPDATE ... WHERE id = ? AND version = ?. If the row moved under us the update matches nothing, so we re-read the current row, merge onto that, and retry. Both PATCHes' fields survive. A full replace still writes unconditionally, since there the caller is sending the whole config and last-writer-wins is the expected behavior.No schema change. The
versioncolumn already shipped with #13976; this just starts using it.Changes
api/v2/mcp.py: version-guarded retry loop for merge PATCHes inupdate_server, unconditional write for full replaces, and a bounded retry budget (returns 409 if it can't settle, which only same-server concurrent PATCHes can trigger).tests/unit/api/v2/test_mcp_db_store.py: one test fires many concurrent PATCHes that each set a different field and checks they all survive; another runs a PATCH against a concurrent DELETE and checks it fails cleanly instead of raising a raw DB error.Summary by CodeRabbit