@@ -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 ( ) => {
0 commit comments