Skip to content

Commit 038a465

Browse files
committed
fix(mothership): keep the best-effort memo in lru-cache and let only its own attempt settle it
- Replace the hand-rolled TTL map with LRUCache (max 1000, ttl 60s), per the repo's caching rule. - A timed-out attempt's late outcome now settles the memo only while the entry is still that attempt's: an older attempt's late success no longer erases a newer attempt's failure, and its late failure no longer recreates a memo after a newer attempt succeeded.
1 parent 4966fe7 commit 038a465

2 files changed

Lines changed: 94 additions & 28 deletions

File tree

‎apps/sim/lib/mothership/mcp-tools.test.ts‎

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,26 @@ describe('mothership MCP best-effort discovery bounds', () => {
9898
const ids = (prefix: string, count: number) =>
9999
Array.from({ length: count }, (_, index) => `${prefix}-${index + 1}`)
100100

101+
let clockStart = 0
102+
let clockOffset = 0
103+
/**
104+
* `lru-cache` captures `performance` at load, so fake timers cannot move the memo's clock: spy it
105+
* forward with the faked `Date` and timers.
106+
*/
107+
async function advanceClock(ms: number): Promise<void> {
108+
clockOffset += ms
109+
vi.spyOn(performance, 'now').mockImplementation(() => clockStart + clockOffset)
110+
await vi.advanceTimersByTimeAsync(ms)
111+
}
112+
101113
beforeEach(() => {
102-
vi.useFakeTimers()
114+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
115+
clockStart = performance.now()
116+
clockOffset = 0
103117
discoverServerTools.mockReset()
104118
})
105119
afterEach(() => {
120+
vi.runOnlyPendingTimers()
106121
vi.useRealTimers()
107122
})
108123

@@ -210,7 +225,7 @@ describe('mothership MCP best-effort discovery bounds', () => {
210225
await query('user-memo-b')
211226
expect(discoverServerTools).toHaveBeenCalledTimes(2)
212227

213-
vi.advanceTimersByTime(60_000)
228+
await advanceClock(61_000)
214229
await query('user-memo-a')
215230
expect(discoverServerTools).toHaveBeenCalledTimes(3)
216231
})
@@ -237,4 +252,54 @@ describe('mothership MCP best-effort discovery bounds', () => {
237252
expect((await query()).tools.map((tool) => tool.service)).toEqual(['mcp:mcp-slow'])
238253
expect(discoverServerTools).toHaveBeenCalledTimes(2)
239254
})
255+
256+
it("keeps a newer attempt's failure when an older attempt succeeds late", async () => {
257+
discoverServerTools
258+
.mockImplementationOnce(
259+
() => new Promise((resolve) => setTimeout(() => resolve(toolsOf('mcp-flaky')), 70_000))
260+
)
261+
.mockRejectedValueOnce(new Error('connect ECONNREFUSED'))
262+
const query = () =>
263+
buildMcpToolSchemas('user-race-a', 'ws-race', { complete: [], bestEffort: ['mcp-flaky'] })
264+
265+
const first = query()
266+
await advanceClock(3_000)
267+
expect((await first).failures.map(({ serverId }) => serverId)).toEqual(['mcp-flaky'])
268+
await advanceClock(61_000)
269+
expect((await query()).failures.map(({ error }) => error.message)).toEqual([
270+
'connect ECONNREFUSED',
271+
])
272+
273+
await advanceClock(6_000)
274+
const third = query()
275+
await flushMicrotasks(10)
276+
expect(discoverServerTools).toHaveBeenCalledTimes(2)
277+
expect((await third).failures.map(({ error }) => error.message)).toEqual([
278+
'connect ECONNREFUSED',
279+
])
280+
})
281+
282+
it('does not remember a server whose older attempt fails late after a newer one succeeded', async () => {
283+
discoverServerTools
284+
.mockImplementationOnce(
285+
() =>
286+
new Promise((_resolve, reject) =>
287+
setTimeout(() => reject(new Error('connect ETIMEDOUT')), 70_000)
288+
)
289+
)
290+
.mockResolvedValueOnce(toolsOf('mcp-flaky'))
291+
.mockResolvedValueOnce(toolsOf('mcp-flaky'))
292+
const query = () =>
293+
buildMcpToolSchemas('user-race-b', 'ws-race', { complete: [], bestEffort: ['mcp-flaky'] })
294+
295+
const first = query()
296+
await advanceClock(3_000)
297+
expect((await first).failures.map(({ serverId }) => serverId)).toEqual(['mcp-flaky'])
298+
await advanceClock(61_000)
299+
expect((await query()).tools.map((tool) => tool.service)).toEqual(['mcp:mcp-flaky'])
300+
301+
await advanceClock(6_000)
302+
expect((await query()).tools.map((tool) => tool.service)).toEqual(['mcp:mcp-flaky'])
303+
expect(discoverServerTools).toHaveBeenCalledTimes(3)
304+
})
240305
})

‎apps/sim/lib/mothership/mcp-tools.ts‎

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { toError } from '@sim/utils/errors'
2+
import { LRUCache } from 'lru-cache'
23
import { mapSettledWithConcurrency } from '@/lib/core/utils/concurrency'
34
import { DeadlineExceededError, withinDeadline } from '@/lib/core/utils/deadline'
45
import { discoverMcpServerToolsAsExecutor } from '@/lib/internal/mcp/discover-tools'
@@ -33,29 +34,21 @@ const BEST_EFFORT_DISCOVERY_START_WINDOW_MS = 3_000
3334
const BEST_EFFORT_RETRY_AFTER_MS = 60_000
3435
const MAX_RECENT_BEST_EFFORT_FAILURES = 1_000
3536

37+
/** One attempt's failure; the entry's identity is that attempt's, so a later attempt's entry is never its to settle. */
38+
interface BestEffortFailure {
39+
error: Error
40+
}
41+
3642
/**
3743
* Best-effort servers that recently failed for a requester, keyed by
38-
* `workspaceId:serverId:userId`, oldest first. It lives in this process because
39-
* best-effort discovery must leave the server's shared status and cooldown to
40-
* the discoveries someone asked for.
44+
* `workspaceId:serverId:userId`. It lives in this process because best-effort
45+
* discovery must leave the server's shared status and cooldown to the
46+
* discoveries someone asked for.
4147
*/
42-
const recentBestEffortFailures = new Map<string, { error: Error; retryAfter: number }>()
43-
44-
function recentBestEffortFailure(key: string): Error | undefined {
45-
const failure = recentBestEffortFailures.get(key)
46-
if (!failure) return undefined
47-
if (failure.retryAfter > Date.now()) return failure.error
48-
recentBestEffortFailures.delete(key)
49-
return undefined
50-
}
51-
52-
function rememberBestEffortFailure(key: string, error: Error): void {
53-
recentBestEffortFailures.delete(key)
54-
recentBestEffortFailures.set(key, { error, retryAfter: Date.now() + BEST_EFFORT_RETRY_AFTER_MS })
55-
if (recentBestEffortFailures.size <= MAX_RECENT_BEST_EFFORT_FAILURES) return
56-
const oldest = recentBestEffortFailures.keys().next()
57-
if (!oldest.done) recentBestEffortFailures.delete(oldest.value)
58-
}
48+
const recentBestEffortFailures = new LRUCache<string, BestEffortFailure>({
49+
max: MAX_RECENT_BEST_EFFORT_FAILURES,
50+
ttl: BEST_EFFORT_RETRY_AFTER_MS,
51+
})
5952

6053
interface McpServerDiscoveryFailure {
6154
serverId: string
@@ -142,8 +135,8 @@ export async function buildMcpToolSchemas(
142135
/** Ignores the deadline's signal so a slow server's discovery still finishes and warms the cache. */
143136
const discoverBestEffort = async (serverId: string) => {
144137
const failureKey = `${workspaceId}:${serverId}:${userId}`
145-
const recentFailure = recentBestEffortFailure(failureKey)
146-
if (recentFailure) throw recentFailure
138+
const recentFailure = recentBestEffortFailures.get(failureKey)
139+
if (recentFailure) throw recentFailure.error
147140
if (Date.now() >= startWindowEndsAt) throw new DeadlineExceededError()
148141
const discovery = discoverMcpServerToolsUseCase.execute({
149142
principal: principal(),
@@ -156,12 +149,20 @@ export async function buildMcpToolSchemas(
156149
)
157150
return tools
158151
} catch (error) {
159-
rememberBestEffortFailure(failureKey, toError(error))
160-
if (error instanceof DeadlineExceededError)
152+
const failure: BestEffortFailure = { error: toError(error) }
153+
recentBestEffortFailures.set(failureKey, failure)
154+
if (error instanceof DeadlineExceededError) {
155+
const isLatest = () =>
156+
recentBestEffortFailures.peek(failureKey, { allowStale: true }) === failure
161157
discovery.then(
162-
() => recentBestEffortFailures.delete(failureKey),
163-
(lateError: unknown) => rememberBestEffortFailure(failureKey, toError(lateError))
158+
() => {
159+
if (isLatest()) recentBestEffortFailures.delete(failureKey)
160+
},
161+
(lateError: unknown) => {
162+
if (isLatest()) recentBestEffortFailures.set(failureKey, { error: toError(lateError) })
163+
}
164164
)
165+
}
165166
throw error
166167
}
167168
}

0 commit comments

Comments
 (0)