@@ -505,7 +505,7 @@ describe('McpService.discoverTools per-server caching', () => {
505505 expect ( mockListTools ) . not . toHaveBeenCalled ( )
506506 } )
507507
508- it ( 'falls back to unordered cache writes when mutation ordering is unavailable ' , async ( ) => {
508+ it ( 'retries mutation ownership before publishing discovery state ' , async ( ) => {
509509 const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
510510 const failureKey = `${ serverKey } :failure`
511511 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
@@ -518,36 +518,123 @@ describe('McpService.discoverTools per-server caching', () => {
518518 expect ( tools ) . toEqual ( [ tool ( 'a1' , 'mcp-a' ) ] )
519519 expect ( cacheStore . get ( serverKey ) ?. tools ) . toEqual ( [ tool ( 'a1' , 'mcp-a' ) ] )
520520 expect ( cacheStore . has ( failureKey ) ) . toBe ( false )
521- expect ( mockCacheAdapter . set ) . toHaveBeenCalledWith (
521+ expect ( mockCacheAdapter . beginMutation ) . toHaveBeenCalledTimes ( 2 )
522+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenCalledWith (
522523 serverKey ,
523- [ tool ( 'a1' , 'mcp-a' ) ] ,
524- expect . any ( Number )
524+ expect . any ( Number ) ,
525+ { key : serverKey , tools : [ tool ( 'a1' , 'mcp-a' ) ] , ttlMs : expect . any ( Number ) } ,
526+ [ failureKey ]
525527 )
526- expect ( mockCacheAdapter . delete ) . toHaveBeenCalledWith ( failureKey )
528+ expect ( mockCacheAdapter . set ) . not . toHaveBeenCalled ( )
529+ expect ( mockCacheAdapter . delete ) . not . toHaveBeenCalled ( )
527530 expect ( mockUpdateSet ) . toHaveBeenCalledWith (
528531 expect . objectContaining ( { connectionStatus : 'connected' , toolCount : 1 } )
529532 )
530533 } )
531534
532- it ( 'publishes database status when both ordered and fallback cache writes fail' , async ( ) => {
535+ it ( 'keeps an older ordered publisher from superseding a retry-acquired mutation' , async ( ) => {
536+ const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
537+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
538+
539+ let resolveOlder : ( ( tools : ReturnType < typeof tool > [ ] ) => void ) | undefined
540+ mockListTools
541+ . mockReturnValueOnce (
542+ new Promise ( ( resolve ) => {
543+ resolveOlder = resolve
544+ } )
545+ )
546+ . mockResolvedValueOnce ( [ tool ( 'new-tool' , 'mcp-a' ) ] )
547+
548+ const older = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , false )
549+ await vi . waitFor ( ( ) => expect ( mockListTools ) . toHaveBeenCalledTimes ( 1 ) )
550+
551+ mockCacheAdapter . beginMutation . mockRejectedValueOnce ( new Error ( 'transient ordering failure' ) )
552+ const newer = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
553+ await expect ( newer ) . resolves . toEqual ( [ tool ( 'new-tool' , 'mcp-a' ) ] )
554+
555+ resolveOlder ?.( [ tool ( 'old-tool' , 'mcp-a' ) ] )
556+ await expect ( older ) . resolves . toEqual ( [ ] )
557+
558+ expect ( mockCacheAdapter . beginMutation ) . toHaveBeenCalledTimes ( 3 )
559+ expect ( cacheStore . get ( serverKey ) ?. tools ) . toEqual ( [ tool ( 'new-tool' , 'mcp-a' ) ] )
560+ expect ( mockUpdateSet ) . toHaveBeenCalledTimes ( 1 )
561+ } )
562+
563+ it ( 'fails a newer discovery closed while an older mutation remains the owner' , async ( ) => {
564+ const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
565+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
566+
567+ let resolveOlder : ( ( tools : ReturnType < typeof tool > [ ] ) => void ) | undefined
568+ mockListTools
569+ . mockReturnValueOnce (
570+ new Promise ( ( resolve ) => {
571+ resolveOlder = resolve
572+ } )
573+ )
574+ . mockResolvedValueOnce ( [ tool ( 'unowned-new-tool' , 'mcp-a' ) ] )
575+
576+ const older = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , false )
577+ await vi . waitFor ( ( ) => expect ( mockListTools ) . toHaveBeenCalledTimes ( 1 ) )
578+
579+ mockCacheAdapter . beginMutation
580+ . mockRejectedValueOnce ( new Error ( 'ordering unavailable' ) )
581+ . mockRejectedValueOnce ( new Error ( 'ordering unavailable' ) )
582+ const newer = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
583+ await expect ( newer ) . resolves . toEqual ( [ ] )
584+
585+ resolveOlder ?.( [ tool ( 'owned-old-tool' , 'mcp-a' ) ] )
586+ await expect ( older ) . resolves . toEqual ( [ tool ( 'owned-old-tool' , 'mcp-a' ) ] )
587+
588+ expect ( mockCacheAdapter . beginMutation ) . toHaveBeenCalledTimes ( 3 )
589+ expect ( cacheStore . get ( serverKey ) ?. tools ) . toEqual ( [ tool ( 'owned-old-tool' , 'mcp-a' ) ] )
590+ expect ( mockCacheAdapter . set ) . not . toHaveBeenCalled ( )
591+ expect ( mockCacheAdapter . delete ) . not . toHaveBeenCalled ( )
592+ expect ( mockUpdateSet ) . toHaveBeenCalledTimes ( 1 )
593+ } )
594+
595+ it ( 'fails discovery publication closed when mutation ownership stays unavailable' , async ( ) => {
533596 const reflectedCredential = 'opaque-cache-provider-message'
534597 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
535- mockCacheAdapter . beginMutation . mockRejectedValueOnce ( new Error ( reflectedCredential ) )
536- mockCacheAdapter . set . mockRejectedValueOnce ( new Error ( reflectedCredential ) )
537- mockCacheAdapter . delete . mockRejectedValueOnce ( new Error ( reflectedCredential ) )
598+ mockCacheAdapter . beginMutation
599+ . mockRejectedValueOnce ( new Error ( reflectedCredential ) )
600+ . mockRejectedValueOnce ( new Error ( reflectedCredential ) )
538601 mockListTools . mockResolvedValueOnce ( [ tool ( 'a1' , 'mcp-a' ) ] )
539602
540603 await expect (
541604 mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
542- ) . resolves . toEqual ( [ tool ( 'a1' , 'mcp-a' ) ] )
605+ ) . resolves . toEqual ( [ ] )
543606
544- expect ( mockUpdateSet ) . toHaveBeenCalledWith (
545- expect . objectContaining ( { connectionStatus : 'connected' , toolCount : 1 } )
607+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . not . toHaveBeenCalled ( )
608+ expect ( mockCacheAdapter . set ) . not . toHaveBeenCalled ( )
609+ expect ( mockCacheAdapter . delete ) . not . toHaveBeenCalled ( )
610+ expect ( mockUpdateSet ) . not . toHaveBeenCalled ( )
611+ expect ( JSON . stringify ( mockLogger ?. warn . mock . calls ) ) . not . toContain ( reflectedCredential )
612+ } )
613+
614+ it ( 'fails discovery publication closed when the atomic cache transition fails' , async ( ) => {
615+ const reflectedCredential = 'opaque-atomic-cache-provider-message'
616+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
617+ mockCacheAdapter . applyMutationIfCurrent . mockRejectedValueOnce ( new Error ( reflectedCredential ) )
618+ mockListTools . mockResolvedValueOnce ( [ tool ( 'a1' , 'mcp-a' ) ] )
619+
620+ await expect (
621+ mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
622+ ) . resolves . toEqual ( [ ] )
623+
624+ expect ( mockCacheAdapter . beginMutation ) . toHaveBeenCalledTimes ( 1 )
625+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenCalledTimes ( 1 )
626+ expect ( mockUpdateSet ) . not . toHaveBeenCalled ( )
627+ expect ( mockLogger ?. warn ) . toHaveBeenCalledWith (
628+ 'Failed to atomically update cache for server mcp-a' ,
629+ expect . objectContaining ( {
630+ workspaceId : WORKSPACE_ID ,
631+ error : expect . objectContaining ( { name : 'Error' } ) ,
632+ } )
546633 )
547634 expect ( JSON . stringify ( mockLogger ?. warn . mock . calls ) ) . not . toContain ( reflectedCredential )
548635 } )
549636
550- it ( 'falls back to unordered deletes when cache invalidation cannot be ordered' , async ( ) => {
637+ it ( 'best-effort deletes both cache keys when invalidation cannot be ordered' , async ( ) => {
551638 const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
552639 const failureKey = `${ serverKey } :failure`
553640 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
@@ -556,7 +643,9 @@ describe('McpService.discoverTools per-server caching', () => {
556643 expiry : Date . now ( ) + 60_000 ,
557644 } )
558645 cacheStore . set ( failureKey , { tools : [ ] , expiry : Date . now ( ) + 60_000 } )
559- mockCacheAdapter . beginMutation . mockRejectedValueOnce ( new Error ( 'cache ordering unavailable' ) )
646+ mockCacheAdapter . beginMutation
647+ . mockRejectedValueOnce ( new Error ( 'cache ordering unavailable' ) )
648+ . mockRejectedValueOnce ( new Error ( 'cache ordering unavailable' ) )
560649
561650 await mcpService . clearCache ( WORKSPACE_ID )
562651
@@ -655,13 +744,10 @@ describe('McpService.discoverTools per-server caching', () => {
655744 expect ( cacheStore . has ( `workspace:${ WORKSPACE_ID } :server:mcp-a` ) ) . toBe ( false )
656745 } )
657746
658- it ( 'orders status publication by discovery start when an older run completes first ' , async ( ) => {
747+ it ( 'supersedes an older discovery before it can publish status ' , async ( ) => {
659748 vi . useFakeTimers ( { toFake : [ 'Date' ] } )
660749 try {
661750 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
662- mockCacheAdapter . beginMutation
663- . mockRejectedValueOnce ( new Error ( 'cache ordering unavailable' ) )
664- . mockRejectedValueOnce ( new Error ( 'cache ordering unavailable' ) )
665751
666752 let resolveOlder : ( ( tools : ReturnType < typeof tool > [ ] ) => void ) | undefined
667753 let resolveNewer : ( ( tools : ReturnType < typeof tool > [ ] ) => void ) | undefined
@@ -689,7 +775,7 @@ describe('McpService.discoverTools per-server caching', () => {
689775
690776 vi . setSystemTime ( new Date ( '2026-02-01T00:00:02.000Z' ) )
691777 resolveOlder ?.( [ tool ( 'old-tool' , 'mcp-a' ) ] )
692- await expect ( older ) . resolves . toEqual ( [ tool ( 'old-tool' , 'mcp-a' ) ] )
778+ await expect ( older ) . resolves . toEqual ( [ ] )
693779
694780 vi . setSystemTime ( new Date ( '2026-02-01T00:00:03.000Z' ) )
695781 resolveNewer ?.( [ tool ( 'new-tool' , 'mcp-a' ) ] )
@@ -699,7 +785,7 @@ describe('McpService.discoverTools per-server caching', () => {
699785 . map ( ( [ update ] ) => update )
700786 . filter ( ( update ) => update . connectionStatus === 'connected' )
701787 . map ( ( update ) => update . lastToolsRefresh )
702- expect ( publishedRefreshTimes ) . toEqual ( [ olderStartedAt , newerStartedAt ] )
788+ expect ( publishedRefreshTimes ) . toEqual ( [ newerStartedAt ] )
703789 } finally {
704790 vi . useRealTimers ( )
705791 }
0 commit comments