diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1546-harden-sync-stall-detection b/projects/packages/premium-analytics/changelog/wooa7s-1546-harden-sync-stall-detection new file mode 100644 index 000000000000..8a814037307c --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1546-harden-sync-stall-detection @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Defensively keep polling when a sync-status poll reads finished before the milestone write lands (100% progress, milestone still unset) instead of treating it as a stall. Not reachable on the default full-sync path; guards the legacy queue-based path against a one-poll false "Sync has stalled" error. diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts index b96ea026ff0a..5a1817f811f4 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts @@ -211,6 +211,41 @@ describe( 'useSyncStatus', () => { expect( result.current.error?.message ).toBe( 'down' ); } ); + it( 'keeps polling when a poll reports finished before the milestone, then completes', async () => { + // Read gap: the first poll sees `finished` + 100% progress while the + // milestone is still 0. It must NOT surface a stall; the next poll exposes + // the milestone and the sync completes. + mockFetch.mockResolvedValueOnce( + rawStatus( { + finished: true, + progress: { woocommerce_analytics: { sent: 2, total: 2 } }, + initial_full_sync_finished: 0, + } ) + ); + mockFetch.mockResolvedValue( + rawStatus( { + finished: true, + progress: { woocommerce_analytics: { sent: 2, total: 2 } }, + initial_full_sync_finished: 1_700_000_000, + } ) + ); + + const { result } = renderHook( () => useSyncStatus() ); + + // First poll: finishing — 100% progress, no stall, no error, polling continues. + await waitFor( () => expect( result.current.data?.percentage ).toBe( 100 ) ); + expect( result.current.isStalled ).toBe( false ); + expect( result.current.error ).toBeNull(); + + // Next tick exposes the milestone ⇒ complete. + await act( async () => { + jest.advanceTimersByTime( POLL_INTERVAL ); + } ); + await waitFor( () => expect( result.current.isComplete ).toBe( true ) ); + expect( result.current.isStalled ).toBe( false ); + expect( result.current.error ).toBeNull(); + } ); + it( 'updates the milestone live from the sync-status poll', async () => { // Milestone unset at page load; the backend then exposes it on the poll. mockFetch.mockResolvedValue( rawStatus( { initial_full_sync_finished: 1_700_000_500 } ) ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts index a764f1926b34..bd370c1e70a0 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts @@ -184,4 +184,29 @@ describe( 'isSyncStalled', () => { } ) ).toBe( false ); } ); + + it( 'is not stalled while finishing — 100% progress with the milestone write still in flight', () => { + // A poll can read `finished: true` before the milestone is persisted; that + // surfaces as 100% progress with the milestone unset. The sync is finishing, + // not stalled, and resolves on the next poll. + expect( + isSyncStalled( { + isStarted: true, + isRunning: false, + percentage: 100, + initialFullSyncFinished: 0, + } ) + ).toBe( false ); + } ); + + it( 'stays stalled when finished below 100% (a genuine stall, not the read gap)', () => { + expect( + isSyncStalled( { + isStarted: true, + isRunning: false, + percentage: 50, + initialFullSyncFinished: 0, + } ) + ).toBe( true ); + } ); } ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/status.ts b/projects/packages/premium-analytics/packages/site-sync/src/status.ts index 9a4d54d0e3c7..6a1bb5e6acd7 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/status.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/status.ts @@ -46,9 +46,15 @@ export function isSyncComplete( status: SyncStatus ): boolean { /** * Stalled = the sync started but is no longer running and hasn't completed. A * sync that never started is NOT stalled — it just needs to be triggered. + * + * 100% progress with the milestone still unset means the sync is *finishing*, + * not stalled: a poll read `finished` before the milestone write landed. Keep + * polling so it self-heals on the next tick. A genuine stall sits below 100%. + * * @param status - Normalized sync status. * @return Whether the sync has stalled. */ export function isSyncStalled( status: SyncStatus ): boolean { - return status.isStarted && ! status.isRunning && ! isSyncComplete( status ); + const isFinishing = status.percentage >= 100 && status.initialFullSyncFinished === 0; + return status.isStarted && ! status.isRunning && ! isSyncComplete( status ) && ! isFinishing; }