@@ -8,50 +8,109 @@ import {
88 resetDbChainMock ,
99 schemaMock ,
1010} from '@sim/testing'
11- import { beforeEach , describe , expect , it } from 'vitest'
11+ import { beforeEach , describe , expect , it , vi } from 'vitest'
12+
13+ const { mockLogError } = vi . hoisted ( ( ) => ( { mockLogError : vi . fn ( ) } ) )
14+ vi . mock ( '@sim/logger' , async ( ) => {
15+ const { createMockLogger } = await import ( '@sim/testing/mocks/logger.mock' )
16+ return { createLogger : ( ) => ( { ...createMockLogger ( ) , error : mockLogError } ) }
17+ } )
18+
1219import {
13- countFailedRunStreak ,
20+ countZeroProgressFailedRuns ,
21+ DATABASE_FAILURE_ALERT_STREAK ,
22+ DATABASE_RETRY_AFTER_PROGRESS_MS ,
1423 databaseRetryDelayMs ,
24+ resolveDatabaseRetryDelayMs ,
1525} from '@/lib/knowledge/connectors/sync-database-retry'
1626import {
1727 CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES ,
1828 MAX_CONSECUTIVE_FAILURES ,
1929} from '@/lib/knowledge/connectors/sync-limits'
2030
2131const MINUTE = 60 * 1000
22- const runs = ( ...statuses : string [ ] ) => statuses . map ( ( status ) => ( { status } ) )
32+ const NO_WRITES = { docsAdded : 0 , docsUpdated : 0 , docsDeleted : 0 }
33+ const contentRun = ( status : string , writes : Partial < typeof NO_WRITES > = { } ) => ( {
34+ status,
35+ ...NO_WRITES ,
36+ ...writes ,
37+ } )
38+ const memberRun = ( status : string , writes : Record < string , number > = { } ) => ( {
39+ status,
40+ membersCompleted : 0 ,
41+ docsAdded : 0 ,
42+ docsUpdated : 0 ,
43+ ...writes ,
44+ } )
2345
24- describe ( 'countFailedRunStreak ' , ( ) => {
46+ describe ( 'countZeroProgressFailedRuns ' , ( ) => {
2547 beforeEach ( ( ) => {
2648 resetDbChainMock ( )
2749 } )
2850
2951 it ( 'counts this run plus the failed runs before it, up to the last one that did not fail' , async ( ) => {
30- queueTableRows (
31- schemaMock . knowledgeConnectorSyncLog ,
32- runs ( 'failed' , 'failed' , 'completed' , 'failed' )
33- )
34- expect ( await countFailedRunStreak ( 'content' , 'c-1' , 'run-1' ) ) . toBe ( 3 )
52+ queueTableRows ( schemaMock . knowledgeConnectorSyncLog , [
53+ contentRun ( 'failed' ) ,
54+ contentRun ( 'failed' ) ,
55+ contentRun ( 'completed' ) ,
56+ contentRun ( 'failed' ) ,
57+ ] )
58+ expect ( await countZeroProgressFailedRuns ( 'content' , 'c-1' , 'run-1' ) ) . toBe ( 3 )
59+ } )
60+
61+ it . each ( [
62+ [ 'added' , { docsAdded : 5 } ] ,
63+ [ 'updated' , { docsUpdated : 1 } ] ,
64+ [ 'deleted' , { docsDeleted : 2 } ] ,
65+ ] ) ( 'ends the streak at a failed run that %s documents' , async ( _label , writes ) => {
66+ queueTableRows ( schemaMock . knowledgeConnectorSyncLog , [
67+ contentRun ( 'failed' ) ,
68+ contentRun ( 'failed' , writes ) ,
69+ contentRun ( 'failed' ) ,
70+ ] )
71+ expect ( await countZeroProgressFailedRuns ( 'content' , 'c-1' , 'run-1' ) ) . toBe ( 2 )
3572 } )
3673
3774 it ( 'counts only this run after a success' , async ( ) => {
38- queueTableRows ( schemaMock . knowledgeConnectorSyncLog , runs ( 'completed' , 'failed' ) )
39- expect ( await countFailedRunStreak ( 'content' , 'c-1' , 'run-1' ) ) . toBe ( 1 )
75+ queueTableRows ( schemaMock . knowledgeConnectorSyncLog , [
76+ contentRun ( 'completed' ) ,
77+ contentRun ( 'failed' ) ,
78+ ] )
79+ expect ( await countZeroProgressFailedRuns ( 'content' , 'c-1' , 'run-1' ) ) . toBe ( 1 )
4080 } )
4181
4282 it ( 'counts an unbroken history in full' , async ( ) => {
43- queueTableRows ( schemaMock . knowledgeConnectorSyncLog , runs ( 'failed' , 'failed' ) )
44- expect ( await countFailedRunStreak ( 'content' , 'c-1' , 'run-1' ) ) . toBe ( 3 )
83+ queueTableRows ( schemaMock . knowledgeConnectorSyncLog , [
84+ contentRun ( 'failed' ) ,
85+ contentRun ( 'failed' ) ,
86+ ] )
87+ expect ( await countZeroProgressFailedRuns ( 'content' , 'c-1' , 'run-1' ) ) . toBe ( 3 )
4588 } )
4689
4790 it ( 'reads the members-mode run log for a members-mode run' , async ( ) => {
48- queueTableRows ( schemaMock . knowledgeConnectorMemberSyncLog , runs ( 'failed' , 'started' ) )
49- expect ( await countFailedRunStreak ( 'member' , 'c-1' , 'run-1' ) ) . toBe ( 2 )
91+ queueTableRows ( schemaMock . knowledgeConnectorMemberSyncLog , [
92+ memberRun ( 'failed' ) ,
93+ memberRun ( 'started' ) ,
94+ ] )
95+ expect ( await countZeroProgressFailedRuns ( 'member' , 'c-1' , 'run-1' ) ) . toBe ( 2 )
96+ } )
97+
98+ it . each ( [
99+ [ 'completed a member' , { membersCompleted : 1 } ] ,
100+ [ 'added documents' , { docsAdded : 3 } ] ,
101+ [ 'updated documents' , { docsUpdated : 1 } ] ,
102+ ] ) ( 'ends a members-mode streak at a failed run that %s' , async ( _label , writes ) => {
103+ queueTableRows ( schemaMock . knowledgeConnectorMemberSyncLog , [
104+ memberRun ( 'failed' ) ,
105+ memberRun ( 'failed' , writes ) ,
106+ memberRun ( 'failed' ) ,
107+ ] )
108+ expect ( await countZeroProgressFailedRuns ( 'member' , 'c-1' , 'run-1' ) ) . toBe ( 2 )
50109 } )
51110
52111 it ( 'excludes the current run and reads only as far back as the ladder climbs' , async ( ) => {
53112 queueTableRows ( schemaMock . knowledgeConnectorSyncLog , [ ] )
54- await countFailedRunStreak ( 'content' , 'c-1' , 'run-1' )
113+ await countZeroProgressFailedRuns ( 'content' , 'c-1' , 'run-1' )
55114 const where = dbChainMockFns . where . mock . calls . at ( - 1 ) ?. [ 0 ]
56115 expect (
57116 hasMockCondition (
@@ -69,7 +128,7 @@ describe('countFailedRunStreak', () => {
69128
70129 it ( 'falls back to this run alone when the history cannot be read' , async ( ) => {
71130 dbChainMockFns . limit . mockRejectedValueOnce ( new Error ( 'canceling statement' ) )
72- expect ( await countFailedRunStreak ( 'content' , 'c-1' , 'run-1' ) ) . toBe ( 1 )
131+ expect ( await countZeroProgressFailedRuns ( 'content' , 'c-1' , 'run-1' ) ) . toBe ( 1 )
73132 } )
74133} )
75134
@@ -96,3 +155,60 @@ describe('databaseRetryDelayMs', () => {
96155 expect ( delay ) . toBeGreaterThanOrEqual ( MAX_CONSECUTIVE_FAILURES * 30 * MINUTE )
97156 } )
98157} )
158+
159+ describe ( 'resolveDatabaseRetryDelayMs' , ( ) => {
160+ beforeEach ( ( ) => {
161+ vi . clearAllMocks ( )
162+ resetDbChainMock ( )
163+ } )
164+
165+ const retry = {
166+ kind : 'content' as const ,
167+ connectorId : 'c-1' ,
168+ runId : 'run-1' ,
169+ previousFailures : 0 ,
170+ }
171+
172+ it ( 'retries shortly after a run that made progress, without reading the streak' , async ( ) => {
173+ queueTableRows (
174+ schemaMock . knowledgeConnectorSyncLog ,
175+ Array . from ( { length : 20 } , ( ) => contentRun ( 'failed' ) )
176+ )
177+ const delay = await resolveDatabaseRetryDelayMs ( { ...retry , madeProgress : true } )
178+ expect ( delay ) . toBeGreaterThanOrEqual ( DATABASE_RETRY_AFTER_PROGRESS_MS )
179+ expect ( delay ) . toBeLessThanOrEqual ( DATABASE_RETRY_AFTER_PROGRESS_MS + MINUTE )
180+ expect ( dbChainMockFns . select ) . not . toHaveBeenCalled ( )
181+ } )
182+
183+ it ( 'climbs the ladder by the zero-progress streak for a run that made none' , async ( ) => {
184+ queueTableRows ( schemaMock . knowledgeConnectorSyncLog , [
185+ contentRun ( 'failed' ) ,
186+ contentRun ( 'failed' ) ,
187+ contentRun ( 'completed' ) ,
188+ ] )
189+ const delay = await resolveDatabaseRetryDelayMs ( { ...retry , madeProgress : false } )
190+ expect ( delay ) . toBeGreaterThanOrEqual ( 90 * MINUTE )
191+ expect ( delay ) . toBeLessThanOrEqual ( 91 * MINUTE )
192+ } )
193+
194+ it ( 'reports a streak that reaches the alert threshold at error level, without disabling' , async ( ) => {
195+ queueTableRows (
196+ schemaMock . knowledgeConnectorSyncLog ,
197+ Array . from ( { length : DATABASE_FAILURE_ALERT_STREAK - 1 } , ( ) => contentRun ( 'failed' ) )
198+ )
199+ await resolveDatabaseRetryDelayMs ( { ...retry , madeProgress : false } )
200+ expect ( mockLogError ) . toHaveBeenCalledWith (
201+ 'Connector sync keeps failing on the database without progress' ,
202+ { connectorId : 'c-1' , kind : 'content' , zeroProgressFailedRuns : DATABASE_FAILURE_ALERT_STREAK }
203+ )
204+ } )
205+
206+ it ( 'stays quiet below the alert threshold' , async ( ) => {
207+ queueTableRows (
208+ schemaMock . knowledgeConnectorSyncLog ,
209+ Array . from ( { length : DATABASE_FAILURE_ALERT_STREAK - 2 } , ( ) => contentRun ( 'failed' ) )
210+ )
211+ await resolveDatabaseRetryDelayMs ( { ...retry , madeProgress : false } )
212+ expect ( mockLogError ) . not . toHaveBeenCalled ( )
213+ } )
214+ } )
0 commit comments