Skip to content

Commit 5bb4402

Browse files
committed
Address PR review feedback (#5754)
- Make cache invalidation retries ownership-safe - Preserve proven newer successes for superseded refreshes
1 parent c0212e5 commit 5bb4402

4 files changed

Lines changed: 142 additions & 55 deletions

File tree

apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,38 @@ describe('MCP server refresh route', () => {
333333
expect(mockUpdate).not.toHaveBeenCalled()
334334
})
335335

336+
it('preserves a newer successful refresh when discovery is superseded', async () => {
337+
mockDiscoverServerTools.mockResolvedValueOnce({
338+
tools: [],
339+
state: 'superseded',
340+
})
341+
const newerSuccessfulServer = {
342+
...initialServer,
343+
lastConnected: new Date(initialServer.lastConnected.getTime() + 60_000),
344+
lastToolsRefresh: new Date(initialServer.lastToolsRefresh.getTime() + 60_000),
345+
toolCount: 7,
346+
}
347+
mockSelect.mockReturnValueOnce(selectRows([newerSuccessfulServer]))
348+
349+
const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
350+
method: 'POST',
351+
}) as NextRequest
352+
const response = await POST(request, { params: Promise.resolve({ id: 'server-1' }) })
353+
const body = await response.json()
354+
355+
expect(body.data).toEqual(
356+
expect.objectContaining({
357+
status: 'connected',
358+
error: null,
359+
toolCount: 7,
360+
workflowsUpdated: 0,
361+
updatedWorkflowIds: [],
362+
})
363+
)
364+
expect(mockSelect).toHaveBeenCalledTimes(2)
365+
expect(mockUpdate).not.toHaveBeenCalled()
366+
})
367+
336368
it('fails closed without syncing workflows when discovery is superseded', async () => {
337369
mockDiscoverServerTools.mockResolvedValueOnce({
338370
tools: [],

apps/sim/app/api/mcp/servers/[id]/refresh/route.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,15 @@ export const POST = withRouteHandler(
265265
let connectionStatus = refreshedServer?.connectionStatus ?? 'error'
266266
let lastError = refreshedServer ? refreshedServer.lastError : discoveryError
267267
let toolCount = refreshedServer?.toolCount ?? discoveredTools.length
268-
269-
if (discoveryState === 'superseded') {
268+
const newerSuccessWonRace =
269+
connectionStatus === 'connected' &&
270+
refreshedServer?.lastToolsRefresh != null &&
271+
(server.lastToolsRefresh == null ||
272+
refreshedServer.lastToolsRefresh > server.lastToolsRefresh) &&
273+
refreshedServer.lastConnected != null &&
274+
(server.lastConnected == null || refreshedServer.lastConnected > server.lastConnected)
275+
276+
if (discoveryState === 'superseded' && !newerSuccessWonRace) {
270277
connectionStatus = 'disconnected'
271278
lastError = 'Tool discovery was superseded by a newer refresh. Please retry.'
272279
toolCount = 0
@@ -280,13 +287,6 @@ export const POST = withRouteHandler(
280287
}
281288

282289
if (discoveryError !== null && connectionStatus === 'connected') {
283-
const newerSuccessWonRace =
284-
refreshedServer?.lastToolsRefresh != null &&
285-
(server.lastToolsRefresh == null ||
286-
refreshedServer.lastToolsRefresh > server.lastToolsRefresh) &&
287-
refreshedServer.lastConnected != null &&
288-
(server.lastConnected == null || refreshedServer.lastConnected > server.lastConnected)
289-
290290
if (!newerSuccessWonRace) {
291291
connectionStatus = 'disconnected'
292292
lastError = oauthAuthorizationRequired ? null : discoveryError

apps/sim/lib/mcp/service.test.ts

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ describe('McpService.discoverTools per-server caching', () => {
716716
expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential)
717717
})
718718

719-
it('best-effort deletes both cache keys when invalidation cannot be ordered', async () => {
719+
it('preserves cache and database state when invalidation cannot be ordered', async () => {
720720
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
721721
const failureKey = `${serverKey}:failure`
722722
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
@@ -725,19 +725,21 @@ describe('McpService.discoverTools per-server caching', () => {
725725
expiry: Date.now() + 60_000,
726726
})
727727
cacheStore.set(failureKey, { tools: [], expiry: Date.now() + 60_000 })
728-
mockCacheAdapter.beginMutation
729-
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
730-
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
728+
for (let attempt = 0; attempt < 6; attempt++) {
729+
mockCacheAdapter.beginMutation.mockRejectedValueOnce(new Error('cache ordering unavailable'))
730+
}
731731

732732
await mcpService.clearCache(WORKSPACE_ID)
733733

734-
expect(cacheStore.has(serverKey)).toBe(false)
735-
expect(cacheStore.has(failureKey)).toBe(false)
736-
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(serverKey)
737-
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(failureKey)
734+
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('stale-tool', 'mcp-a')])
735+
expect(cacheStore.has(failureKey)).toBe(true)
736+
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(6)
737+
expect(mockCacheAdapter.applyMutationIfCurrent).not.toHaveBeenCalled()
738+
expect(mockCacheAdapter.delete).not.toHaveBeenCalled()
739+
expect(mockUpdateSet).not.toHaveBeenCalled()
738740
})
739741

740-
it('best-effort deletes both cache keys and publishes the barrier when ordered invalidation fails', async () => {
742+
it('reacquires ownership after a transient atomic invalidation failure', async () => {
741743
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
742744
const failureKey = `${serverKey}:failure`
743745
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
@@ -752,15 +754,77 @@ describe('McpService.discoverTools per-server caching', () => {
752754

753755
await mcpService.clearCache(WORKSPACE_ID)
754756

755-
const mutationId = mockCacheAdapter.applyMutationIfCurrent.mock.calls[0][1]
757+
const firstMutationId = mockCacheAdapter.applyMutationIfCurrent.mock.calls[0][1]
758+
const successfulMutationId = mockCacheAdapter.applyMutationIfCurrent.mock.calls[1][1]
759+
expect(successfulMutationId).toBeGreaterThan(firstMutationId)
760+
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(2)
761+
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenCalledTimes(2)
756762
expect(cacheStore.has(serverKey)).toBe(false)
757763
expect(cacheStore.has(failureKey)).toBe(false)
758-
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(serverKey)
759-
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(failureKey)
764+
expect(mockCacheAdapter.delete).not.toHaveBeenCalled()
760765
expect(mockUpdateSet).toHaveBeenCalledWith({
761766
toolCount: 0,
762-
lastToolsRefresh: new Date(mutationId),
767+
lastToolsRefresh: new Date(successfulMutationId),
768+
})
769+
})
770+
771+
it('preserves a newer winner acquired between invalidation retry begin and apply', async () => {
772+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
773+
const failureKey = `${serverKey}:failure`
774+
const winner = tool('winner-tool', 'mcp-a')
775+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
776+
cacheStore.set(serverKey, {
777+
tools: [tool('stale-tool', 'mcp-a')],
778+
expiry: Date.now() + 60_000,
763779
})
780+
cacheStore.set(failureKey, { tools: [], expiry: Date.now() + 60_000 })
781+
const defaultApply = mockCacheAdapter.applyMutationIfCurrent.getMockImplementation()
782+
mockCacheAdapter.applyMutationIfCurrent
783+
.mockRejectedValueOnce(new Error('atomic invalidation unavailable'))
784+
.mockImplementationOnce(async (scopeKey, mutationId, setEntry, deleteKeys) => {
785+
const newerMutationId = await mockCacheAdapter.beginMutation(scopeKey)
786+
await defaultApply?.(
787+
scopeKey,
788+
newerMutationId,
789+
{ key: serverKey, tools: [winner], ttlMs: 60_000 },
790+
[failureKey]
791+
)
792+
return defaultApply?.(scopeKey, mutationId, setEntry, deleteKeys) ?? false
793+
})
794+
795+
await mcpService.clearCache(WORKSPACE_ID)
796+
797+
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenCalledTimes(2)
798+
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(3)
799+
expect(cacheStore.get(serverKey)?.tools).toEqual([winner])
800+
expect(cacheStore.has(failureKey)).toBe(false)
801+
expect(mockCacheAdapter.delete).not.toHaveBeenCalled()
802+
expect(mockUpdateSet).not.toHaveBeenCalled()
803+
})
804+
805+
it('bounds persistent atomic invalidation failures without raw deletes', async () => {
806+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
807+
const failureKey = `${serverKey}:failure`
808+
const reflectedCredential = 'opaque-cache-provider-message'
809+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
810+
cacheStore.set(serverKey, {
811+
tools: [tool('stale-tool', 'mcp-a')],
812+
expiry: Date.now() + 60_000,
813+
})
814+
cacheStore.set(failureKey, { tools: [], expiry: Date.now() + 60_000 })
815+
for (let attempt = 0; attempt < 3; attempt++) {
816+
mockCacheAdapter.applyMutationIfCurrent.mockRejectedValueOnce(new Error(reflectedCredential))
817+
}
818+
819+
await mcpService.clearCache(WORKSPACE_ID)
820+
821+
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(3)
822+
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenCalledTimes(3)
823+
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('stale-tool', 'mcp-a')])
824+
expect(cacheStore.has(failureKey)).toBe(true)
825+
expect(mockCacheAdapter.delete).not.toHaveBeenCalled()
826+
expect(mockUpdateSet).not.toHaveBeenCalled()
827+
expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential)
764828
})
765829

766830
it('preserves a newer cache winner when invalidation is superseded', async () => {

apps/sim/lib/mcp/service.ts

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export function getTimestampMillisecondBounds(timestamp: string): {
6868

6969
const FAILURE_CACHE_SENTINEL: McpTool[] = []
7070
const CACHE_MUTATION_BEGIN_ATTEMPTS = 2
71+
const CACHE_INVALIDATION_ATTEMPTS = 3
7172
const STATUS_UPDATE_CAS_ATTEMPTS = 3
7273
const STATUS_METADATA_RACE_RETRY_ATTEMPTS = 3
7374

@@ -702,45 +703,35 @@ class McpService {
702703
return null
703704
}
704705

705-
private async bestEffortInvalidateServerCache(
706-
workspaceId: string,
707-
serverId: string
708-
): Promise<void> {
706+
private async invalidateServerCache(workspaceId: string, serverId: string): Promise<void> {
709707
const keys = [serverCacheKey(workspaceId, serverId), failureCacheKey(workspaceId, serverId)]
710-
const results = await Promise.allSettled(keys.map((key) => this.cacheAdapter.delete(key)))
711-
const failures = results.flatMap((result) =>
712-
result.status === 'rejected' ? [getMcpSafeErrorDiagnostics(result.reason)] : []
713-
)
714-
if (failures.length > 0) {
715-
logger.warn(`Failed best-effort cache invalidation for server ${serverId}`, {
708+
for (let attempt = 0; attempt < CACHE_INVALIDATION_ATTEMPTS; attempt++) {
709+
// Reacquire a fresh ownership token after every unavailable transition.
710+
// This orders the retry after work that completed during the unknown
711+
// attempt, while work starting later can still supersede the new token.
712+
const mutation = await this.beginServerCacheMutation(workspaceId, serverId)
713+
if (!mutation) continue
714+
715+
const cacheApplied = await this.applyServerCacheMutation(
716716
workspaceId,
717-
failures,
718-
})
719-
}
720-
}
717+
serverId,
718+
mutation,
719+
null,
720+
keys
721+
)
722+
if (cacheApplied === 'superseded') return
723+
if (cacheApplied === 'unavailable') continue
721724

722-
private async invalidateServerCache(workspaceId: string, serverId: string): Promise<void> {
723-
const mutation = await this.beginServerCacheMutation(workspaceId, serverId)
724-
if (!mutation) {
725-
// During a total cache-backend outage there is no cross-process token we
726-
// can advance, so this can only be best effort. Deleting both keys still
727-
// prevents stale reads whenever the backend accepts ordinary commands.
728-
await this.bestEffortInvalidateServerCache(workspaceId, serverId)
725+
await this.markServerCacheInvalidated(serverId, workspaceId, new Date(mutation.id))
729726
return
730727
}
731-
const cacheApplied = await this.applyServerCacheMutation(
732-
workspaceId,
733-
serverId,
734-
mutation,
735-
null,
736-
[serverCacheKey(workspaceId, serverId), failureCacheKey(workspaceId, serverId)]
737-
)
738-
if (cacheApplied === 'superseded') return
739-
if (cacheApplied === 'unavailable') {
740-
await this.bestEffortInvalidateServerCache(workspaceId, serverId)
741-
}
742728

743-
await this.markServerCacheInvalidated(serverId, workspaceId, new Date(mutation.id))
729+
// Without an ownership-checked transition, deleting either key can erase
730+
// a newer process's winner. Leave cache and database state unchanged.
731+
logger.warn(`Cache invalidation unavailable for server ${serverId}`, {
732+
workspaceId,
733+
attempts: CACHE_INVALIDATION_ATTEMPTS,
734+
})
744735
}
745736

746737
private async getCurrentCachedTools(

0 commit comments

Comments
 (0)