@@ -751,7 +751,7 @@ describe('capReconciliationDeletions', () => {
751751
752752 expect ( result . held ) . toBe ( false )
753753 expect ( result . cap ) . toBe ( 250 )
754- expect ( result . requested ) . toBe ( 250 )
754+ expect ( result . withheld ) . toBe ( 0 )
755755 expect ( result . softDeleteIds ) . toEqual ( soft )
756756 } )
757757
@@ -761,7 +761,8 @@ describe('capReconciliationDeletions', () => {
761761 const result = capReconciliationDeletions ( ids ( 'soft' , 251 ) , [ ] , 1000 , false )
762762
763763 expect ( result . held ) . toBe ( true )
764- expect ( result . requested ) . toBe ( 251 )
764+ expect ( result . softHeld ) . toBe ( true )
765+ expect ( result . withheld ) . toBe ( 251 )
765766 } )
766767
767768 it ( 'returns empty arrays — not the inputs — when held' , async ( ) => {
@@ -774,25 +775,55 @@ describe('capReconciliationDeletions', () => {
774775 expect ( result . hardDeleteIds ) . toEqual ( [ ] )
775776 } )
776777
777- it ( 'counts the union of soft and hard deletions against one cap ' , async ( ) => {
778+ it ( 'caps each generation separately rather than summing them ' , async ( ) => {
778779 const { capReconciliationDeletions } = await import ( '@/lib/knowledge/connectors/sync-engine' )
779780
780- const overlapping = ids ( 'doc' , 200 )
781- // Same ids on both lists must count once, not twice.
782- expect ( capReconciliationDeletions ( overlapping , overlapping , 1000 , false ) . held ) . toBe ( false )
783- expect ( capReconciliationDeletions ( ids ( 'a' , 200 ) , ids ( 'b' , 200 ) , 1000 , false ) . held ) . toBe ( true )
781+ /**
782+ * Hard deletes are the previous generation's soft deletes, already gated by
783+ * this cap once. Summing them double-counts the older generation, which is
784+ * what deadlocked a churning connector.
785+ */
786+ const result = capReconciliationDeletions ( ids ( 'a' , 200 ) , ids ( 'b' , 200 ) , 1000 , false )
787+
788+ expect ( result . held ) . toBe ( false )
789+ expect ( result . softDeleteIds ) . toHaveLength ( 200 )
790+ expect ( result . hardDeleteIds ) . toHaveLength ( 200 )
784791 } )
785792
786- it ( 'is bypassed by a forced fullSync ' , async ( ) => {
793+ it ( 'holds only the generation that breached the cap ' , async ( ) => {
787794 const { capReconciliationDeletions } = await import ( '@/lib/knowledge/connectors/sync-engine' )
788795
789- const hard = ids ( 'hard' , 1000 )
790- const result = capReconciliationDeletions ( [ ] , hard , 1000 , true )
796+ const hard = ids ( 'hard' , 100 )
797+ const result = capReconciliationDeletions ( ids ( 'soft' , 400 ) , hard , 1000 , false )
791798
792- expect ( result . held ) . toBe ( false )
799+ expect ( result . softHeld ) . toBe ( true )
800+ expect ( result . hardHeld ) . toBe ( false )
801+ expect ( result . softDeleteIds ) . toEqual ( [ ] )
802+ // The confirmed generation still drains, so the backlog cannot ratchet.
793803 expect ( result . hardDeleteIds ) . toEqual ( hard )
794804 } )
795805
806+ it ( 'is bypassed by a forced fullSync, in both generations' , async ( ) => {
807+ const { capReconciliationDeletions } = await import ( '@/lib/knowledge/connectors/sync-engine' )
808+
809+ const hard = ids ( 'hard' , 1000 )
810+ const hardOnly = capReconciliationDeletions ( [ ] , hard , 1000 , true )
811+
812+ expect ( hardOnly . held ) . toBe ( false )
813+ expect ( hardOnly . hardDeleteIds ) . toEqual ( hard )
814+
815+ /**
816+ * Exercised per generation: asserting only the hard list left the soft
817+ * branch's bypass untested, so dropping it there was invisible.
818+ */
819+ const soft = ids ( 'soft' , 1000 )
820+ const softOnly = capReconciliationDeletions ( soft , [ ] , 1000 , true )
821+
822+ expect ( softOnly . held ) . toBe ( false )
823+ expect ( softOnly . softHeld ) . toBe ( false )
824+ expect ( softOnly . softDeleteIds ) . toEqual ( soft )
825+ } )
826+
796827 it ( 'applies the small-corpus floor rather than the ratio' , async ( ) => {
797828 const { capReconciliationDeletions } = await import ( '@/lib/knowledge/connectors/sync-engine' )
798829
@@ -814,6 +845,28 @@ describe('capReconciliationDeletions', () => {
814845 ) . toBe ( true )
815846 } )
816847
848+ describe ( 'steady churn' , ( ) => {
849+ it ( 'reaches a stable state instead of ratcheting shut' , async ( ) => {
850+ const { capReconciliationDeletions } = await import ( '@/lib/knowledge/connectors/sync-engine' )
851+
852+ /**
853+ * 1,000 documents at 15% churn against a cap of 250. Under one summed cap:
854+ * sync 1 applied 150 soft; sync 2 requested 150 soft + 150 hard = 300 and
855+ * was held in full; the blocked hard deletes then accumulated forever.
856+ */
857+ const sync1 = capReconciliationDeletions ( ids ( 'gen1' , 150 ) , [ ] , 1000 , false )
858+ expect ( sync1 . held ) . toBe ( false )
859+
860+ const sync2 = capReconciliationDeletions ( ids ( 'gen2' , 150 ) , ids ( 'gen1' , 150 ) , 1000 , false )
861+ expect ( sync2 . held ) . toBe ( false )
862+ expect ( sync2 . hardDeleteIds ) . toHaveLength ( 150 )
863+
864+ const sync3 = capReconciliationDeletions ( ids ( 'gen3' , 150 ) , ids ( 'gen2' , 150 ) , 1000 , false )
865+ expect ( sync3 . held ) . toBe ( false )
866+ expect ( sync3 . hardDeleteIds ) . toHaveLength ( 150 )
867+ } )
868+ } )
869+
817870 describe ( 'confirmed data-loss shapes' , ( ) => {
818871 it ( 'holds a partial outage that returns half a 1000-document corpus' , async ( ) => {
819872 const { capReconciliationDeletions } = await import ( '@/lib/knowledge/connectors/sync-engine' )
@@ -1341,3 +1394,72 @@ describe('buildSyncLockAcquisition', () => {
13411394 expect ( acquisition . status ) . toBe ( 'syncing' )
13421395 } )
13431396} )
1397+
1398+ describe ( 'shouldHeartbeatSyncLock' , ( ) => {
1399+ it ( 'beats once the interval has elapsed' , async ( ) => {
1400+ const { shouldHeartbeatSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1401+
1402+ expect ( shouldHeartbeatSyncLock ( 1_000 , 0 , 1_000 ) ) . toBe ( true )
1403+ expect ( shouldHeartbeatSyncLock ( 1_001 , 0 , 1_000 ) ) . toBe ( true )
1404+ } )
1405+
1406+ it ( 'does not beat before the interval has elapsed' , async ( ) => {
1407+ const { shouldHeartbeatSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1408+
1409+ expect ( shouldHeartbeatSyncLock ( 999 , 0 , 1_000 ) ) . toBe ( false )
1410+ expect ( shouldHeartbeatSyncLock ( 0 , 0 , 1_000 ) ) . toBe ( false )
1411+ } )
1412+
1413+ it ( 'defaults to an interval far below the reclaim TTL' , async ( ) => {
1414+ const { shouldHeartbeatSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1415+ const { CONNECTOR_SYNC_STALE_LOCK_TTL_MS , SYNC_LOCK_HEARTBEAT_INTERVAL_MS } = await import (
1416+ '@/lib/knowledge/connectors/sync-limits'
1417+ )
1418+
1419+ /**
1420+ * A live run must beat many times over before the reclaim cutoff, or
1421+ * ordinary jitter reclaims a working sync — which is what made the reaper a
1422+ * one-way ratchet to `disabled` for slow in-process syncs.
1423+ */
1424+ expect ( SYNC_LOCK_HEARTBEAT_INTERVAL_MS * 4 ) . toBeLessThan ( CONNECTOR_SYNC_STALE_LOCK_TTL_MS )
1425+ expect ( shouldHeartbeatSyncLock ( SYNC_LOCK_HEARTBEAT_INTERVAL_MS , 0 ) ) . toBe ( true )
1426+ expect ( shouldHeartbeatSyncLock ( SYNC_LOCK_HEARTBEAT_INTERVAL_MS - 1 , 0 ) ) . toBe ( false )
1427+ } )
1428+ } )
1429+
1430+ describe ( 'heartbeatSyncLock' , ( ) => {
1431+ beforeEach ( ( ) => {
1432+ vi . clearAllMocks ( )
1433+ resetDbChainMock ( )
1434+ } )
1435+
1436+ it ( 'refreshes updatedAt under the run own lock guard' , async ( ) => {
1437+ const { heartbeatSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1438+
1439+ await heartbeatSyncLock ( 'c-1' , 'run-a' )
1440+
1441+ expect ( dbChainMockFns . set . mock . calls [ 0 ] [ 0 ] ) . toEqual ( { updatedAt : expect . any ( Date ) } )
1442+
1443+ // Guarded, so a beat doubles as an ownership probe rather than a blind touch.
1444+ const where = dbChainMockFns . where . mock . calls [ 0 ] [ 0 ]
1445+ expect (
1446+ hasMockCondition (
1447+ where ,
1448+ ( node : MockCondition ) =>
1449+ node . type === 'eq' &&
1450+ node . left === schemaMock . knowledgeConnector . syncLockToken &&
1451+ node . right === 'run-a'
1452+ )
1453+ ) . toBe ( true )
1454+ } )
1455+
1456+ it ( 'reports a lost lock so the run can stop instead of racing its replacement' , async ( ) => {
1457+ const { heartbeatSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1458+
1459+ dbChainMockFns . returning . mockResolvedValueOnce ( [ ] )
1460+ expect ( await heartbeatSyncLock ( 'c-1' , 'run-a' ) ) . toBe ( false )
1461+
1462+ dbChainMockFns . returning . mockResolvedValueOnce ( [ { id : 'c-1' } ] )
1463+ expect ( await heartbeatSyncLock ( 'c-1' , 'run-a' ) ) . toBe ( true )
1464+ } )
1465+ } )
0 commit comments