@@ -71,6 +71,7 @@ vi.mock('@/lib/knowledge/connectors/sync-persistence', () => ({
7171 persistSkippedDocuments : vi . fn ( async ( ) => [ ] ) ,
7272 persistSourceDocumentFailures : mocks . persistFailures ,
7373 persistHashOnlyUpdates : vi . fn ( async ( ) => [ ] ) ,
74+ resolveSourceMetadataFields : vi . fn ( ( ) => ( { sourceUrl : null , sourceModifiedAt : null } ) ) ,
7475} ) )
7576vi . mock ( '@/lib/knowledge/documents/service' , ( ) => ( {
7677 isTriggerAvailable : ( ) => true ,
@@ -117,6 +118,7 @@ vi.mock('@/connectors/registry.server', () => ({
117118 } ,
118119} ) )
119120
121+ import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error'
120122import {
121123 CredentialGroupCredentialCursorNotFoundError ,
122124 loadScopedAccountsCredentialListContext ,
@@ -126,7 +128,10 @@ import {
126128 listingFingerprint ,
127129} from '@/lib/knowledge/connectors/listing-checkpoint'
128130import { executeMemberSync } from '@/lib/knowledge/connectors/member-sync-engine'
129- import { SOURCE_CONTENT_ERROR } from '@/lib/knowledge/connectors/sync-limits'
131+ import {
132+ MEMBER_SCOPE_RENEWAL_PREFIX_BATCH ,
133+ SOURCE_CONTENT_ERROR ,
134+ } from '@/lib/knowledge/connectors/sync-limits'
130135
131136const serviceDocument : ExternalDocument = {
132137 externalId : 'file-shared' ,
@@ -858,23 +863,59 @@ describe('member engine with a dedicated content credential', () => {
858863 scopeRenewalCursor : null ,
859864 scopeRenewalStartedAt : null ,
860865 } )
866+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
867+ expect . objectContaining ( { completedAt : expect . any ( Date ) , observationsRenewed : 3 } )
868+ )
861869 } )
862870
863- it ( 'renews every page of scopes before recording the renewal' , async ( ) => {
871+ it ( 'records renewed observations on the log of a deferred run' , async ( ) => {
872+ const run = arrange ( { connectorType : 'scoped_listing' , members : true , contentFresh : true } )
873+ mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-a:' ] } )
874+ mocks . renew . mockResolvedValue ( { renewed : 2 , finished : true } )
875+ mocks . list . mockRejectedValue ( new ProviderCapacityDeferredError ( 'admission_unavailable' ) )
876+ expect ( ( await run ( ) ) . deferred ) . toBeDefined ( )
877+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
878+ expect . objectContaining ( { completedAt : expect . any ( Date ) , observationsRenewed : 2 } )
879+ )
880+ } )
881+
882+ it ( 'gathers scope pages into one pass over the stale observations' , async ( ) => {
864883 const run = arrange ( { connectorType : 'scoped_listing' , members : true , contentFresh : true } )
865884 mocks . scopes
866885 . mockResolvedValueOnce ( { prefixes : [ 'source:container-a:' ] , nextCursor : 'page-2' } )
867886 . mockResolvedValueOnce ( { prefixes : [ 'source:container-b:' ] } )
868887 mocks . renew . mockResolvedValue ( { renewed : 2 , finished : true } )
869- expect ( ( await run ( ) ) . observationsRenewed ) . toBe ( 4 )
888+ expect ( ( await run ( ) ) . observationsRenewed ) . toBe ( 2 )
870889 expect ( mocks . scopes . mock . calls . map ( ( call ) => call [ 2 ] ) ) . toEqual ( [ undefined , 'page-2' ] )
871890 expect ( mocks . renew . mock . calls . map ( ( [ call ] ) => call . scopePrefixes ) ) . toEqual ( [
872- [ 'source:container-a:' ] ,
873- [ 'source:container-b:' ] ,
891+ [ 'source:container-a:' , 'source:container-b:' ] ,
874892 ] )
875893 expect ( scopeRenewal ( ) ) . toMatchObject ( { scopeRenewedAt : expect . any ( Date ) } )
876894 } )
877895
896+ it ( 'renews a full batch of scopes before reading more, and resumes an unfinished batch' , async ( ) => {
897+ const run = arrange ( { connectorType : 'scoped_listing' , members : true , contentFresh : true } )
898+ const fullBatch = Array . from (
899+ { length : MEMBER_SCOPE_RENEWAL_PREFIX_BATCH } ,
900+ ( _ , index ) => `source:container-${ index } :`
901+ )
902+ mocks . scopes
903+ . mockResolvedValueOnce ( { prefixes : fullBatch , nextCursor : 'page-2' } )
904+ . mockResolvedValueOnce ( { prefixes : [ 'source:container-last:' ] } )
905+ mocks . renew
906+ . mockResolvedValueOnce ( { renewed : 5 , finished : true } )
907+ . mockResolvedValueOnce ( { renewed : 1 , finished : false } )
908+ expect ( ( await run ( ) ) . observationsRenewed ) . toBe ( 6 )
909+ expect ( mocks . renew . mock . calls . map ( ( [ call ] ) => call . scopePrefixes . length ) ) . toEqual ( [
910+ MEMBER_SCOPE_RENEWAL_PREFIX_BATCH ,
911+ 1 ,
912+ ] )
913+ expect ( scopeRenewal ( ) ) . toEqual ( {
914+ scopeRenewalCursor : 'page-2' ,
915+ scopeRenewalStartedAt : expect . any ( Date ) ,
916+ } )
917+ } )
918+
878919 it ( 'does not renew again while the last renewal is recent' , async ( ) => {
879920 const run = arrange ( {
880921 connectorType : 'scoped_listing' ,
@@ -894,7 +935,7 @@ describe('member engine with a dedicated content credential', () => {
894935 contentFresh : true ,
895936 scopeRenewal : { cursor : 'page-7' , startedAt : new Date ( '2026-09-01T00:00:00Z' ) } ,
896937 } )
897- mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-a:' ] , nextCursor : 'page-8' } )
938+ mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-a:' ] } )
898939 mocks . renew . mockResolvedValue ( { renewed : 1000 , finished : false } )
899940 expect ( ( await run ( ) ) . observationsRenewed ) . toBe ( 1000 )
900941 expect ( mocks . scopes . mock . calls [ 0 ] ?. [ 2 ] ) . toBe ( 'page-7' )
@@ -923,6 +964,16 @@ describe('member engine with a dedicated content credential', () => {
923964 } )
924965 } )
925966
967+ it ( 'stops a scope listing whose cursor does not advance' , async ( ) => {
968+ const run = arrange ( { connectorType : 'scoped_listing' , members : true , contentFresh : true } )
969+ mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-a:' ] , nextCursor : 'page-2' } )
970+ mocks . renew . mockResolvedValue ( { renewed : 1 , finished : true } )
971+ expect ( ( await run ( ) ) . error ) . toBeUndefined ( )
972+ expect ( mocks . scopes ) . toHaveBeenCalledTimes ( 2 )
973+ expect ( mocks . renew ) . not . toHaveBeenCalled ( )
974+ expect ( mocks . observe ) . toHaveBeenCalled ( )
975+ } )
976+
926977 it ( 'restarts a pass whose stored cursor expired' , async ( ) => {
927978 const run = arrange ( {
928979 connectorType : 'scoped_listing' ,
0 commit comments