From d7e3127e65f7fa2b92c820bb999f77c91f1abb2e Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Thu, 23 Dec 2021 16:27:03 +0800 Subject: [PATCH] Sync: handle fast-actions being reverted ## Repro 1. Follow a channel. 2. When `preference_set` is sent, unfollow the channel. 3. A few seconds later, the final setting reflects (1) instead of (2). The current sync loop involves doing a final `sync/get` at the end. While not necessary for the scenario above, the code flow covers various cases, so it's still needed for now. ## Approach Implement an abort mechanism to the sync-loop. When syncing from the `buildSharedStateMiddleware` loop, generate an ID for each sync session, and only store the latest one. Pass the ID to the completion-callback (and other places as needed), so we can check if our session is still the latest one before executing the callback. The check for invalidation can also be placed in more places to cut off the sync process earlier, but it's only done for 2 critical places for now. --- flow-typed/sync.js | 1 + ui/constants/action_types.js | 1 + ui/redux/actions/app.js | 19 +++++++++++++------ ui/redux/actions/sync.js | 24 ++++++++++++++++++++++-- ui/redux/middleware/shared-state.js | 5 ++++- ui/redux/reducers/sync.js | 5 +++++ ui/store.js | 4 ++-- 7 files changed, 48 insertions(+), 11 deletions(-) diff --git a/flow-typed/sync.js b/flow-typed/sync.js index b30603340..cd0a323ef 100644 --- a/flow-typed/sync.js +++ b/flow-typed/sync.js @@ -11,6 +11,7 @@ declare type SyncState = { setSyncIsPending: boolean, prefsReady: boolean, syncLocked: boolean, + sharedStateSyncId: number, hashChanged: boolean, fatalError: boolean, }; diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 148175c6d..4f029e169 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -451,6 +451,7 @@ export const SYNC_APPLY_BAD_PASSWORD = 'SYNC_APPLY_BAD_PASSWORD'; export const SYNC_RESET = 'SYNC_RESET'; export const SYNC_FATAL_ERROR = 'SYNC_FATAL_ERROR'; export const USER_STATE_POPULATE = 'USER_STATE_POPULATE'; +export const SHARED_STATE_SYNC_ID_CHANGED = 'SHARED_STATE_SYNC_ID_CHANGED'; export const REACTIONS_LIST_STARTED = 'REACTIONS_LIST_STARTED'; export const REACTIONS_LIST_FAILED = 'REACTIONS_LIST_FAILED'; diff --git a/ui/redux/actions/app.js b/ui/redux/actions/app.js index 61da1cdf8..44afb1ee8 100644 --- a/ui/redux/actions/app.js +++ b/ui/redux/actions/app.js @@ -42,7 +42,7 @@ import { } from 'redux/selectors/app'; import { selectDaemonSettings, selectClientSetting } from 'redux/selectors/settings'; import { selectUser, selectUserVerifiedEmail } from 'redux/selectors/user'; -import { doSetPrefsReady, doPreferenceGet, doPopulateSharedUserState } from 'redux/actions/sync'; +import { doSetPrefsReady, doPreferenceGet, doPopulateSharedUserState, syncInvalidated } from 'redux/actions/sync'; import { doAuthenticate } from 'redux/actions/user'; import { lbrySettings as config, version as appVersion } from 'package.json'; import analytics, { SHARE_INTERNAL } from 'analytics'; @@ -596,7 +596,7 @@ export function doToggle3PAnalytics(allowParam, doNotDispatch) { }; } -export function doGetAndPopulatePreferences() { +export function doGetAndPopulatePreferences(syncId /* ?: number */) { const { SDK_SYNC_KEYS } = SHARED_PREFERENCES; return (dispatch, getState) => { @@ -615,7 +615,10 @@ export function doGetAndPopulatePreferences() { const successState = getState(); const daemonSettings = selectDaemonSettings(successState); if (savedPreferences !== null) { - dispatch(doPopulateSharedUserState(savedPreferences)); + if (!syncInvalidated(getState, syncId)) { + dispatch(doPopulateSharedUserState(savedPreferences)); + } + // @if TARGET='app' const { settings } = savedPreferences.value; @@ -660,11 +663,15 @@ export function doGetAndPopulatePreferences() { }; } -export function doHandleSyncComplete(error, hasNewData) { - return (dispatch) => { +export function doHandleSyncComplete(error, hasNewData, syncId) { + return (dispatch, getState) => { if (!error) { if (hasNewData) { - dispatch(doGetAndPopulatePreferences()); + if (syncInvalidated(getState, syncId)) { + return; + } + + dispatch(doGetAndPopulatePreferences(syncId)); // we just got sync data, better update our channels dispatch(doFetchChannelListMine()); } diff --git a/ui/redux/actions/sync.js b/ui/redux/actions/sync.js index aa76f22a7..727cffcac 100644 --- a/ui/redux/actions/sync.js +++ b/ui/redux/actions/sync.js @@ -21,6 +21,19 @@ const SYNC_INTERVAL = 1000 * 60 * 5; // 5 minutes const NO_WALLET_ERROR = 'no wallet found for this user'; const BAD_PASSWORD_ERROR_NAME = 'InvalidPasswordError'; +/** + * Checks if there is a newer sync session, indicating that fetched data from + * the current one can be dropped. + * + * @param getState + * @param syncId [Optional] The id of the current sync session. If not given, assume not invalidated. + * @returns {boolean} + */ +export function syncInvalidated(getState: GetState, syncId?: number) { + const state = getState(); + return syncId && state.sync.sharedStateSyncId !== syncId; +} + export function doSetDefaultAccount(success: () => void, failure: (string) => void) { return (dispatch: Dispatch) => { dispatch({ @@ -115,7 +128,14 @@ export const doGetSyncDesktop = (cb?: (any, any) => void, password?: string) => }); }; -export function doSyncLoop(noInterval?: boolean) { +/** + * doSyncLoop + * + * @param noInterval + * @param syncId Optional ID to identify a specific loop. Can be used to abort the loop, for example. + * @returns {(function(Dispatch, GetState): void)|*} + */ +export function doSyncLoop(noInterval?: boolean, syncId?: number) { return (dispatch: Dispatch, getState: GetState) => { if (!noInterval && syncTimer) clearInterval(syncTimer); const state = getState(); @@ -123,7 +143,7 @@ export function doSyncLoop(noInterval?: boolean) { const syncEnabled = selectClientSetting(state, SETTINGS.ENABLE_SYNC); const syncLocked = selectSyncIsLocked(state); if (hasVerifiedEmail && syncEnabled && !syncLocked) { - dispatch(doGetSyncDesktop((error, hasNewData) => dispatch(doHandleSyncComplete(error, hasNewData)))); + dispatch(doGetSyncDesktop((error, hasNewData) => dispatch(doHandleSyncComplete(error, hasNewData, syncId)))); if (!noInterval) { syncTimer = setInterval(() => { const state = getState(); diff --git a/ui/redux/middleware/shared-state.js b/ui/redux/middleware/shared-state.js index 5bac9231f..91a608f1f 100644 --- a/ui/redux/middleware/shared-state.js +++ b/ui/redux/middleware/shared-state.js @@ -1,4 +1,5 @@ // @flow +import * as ACTIONS from 'constants/action_types'; import isEqual from 'util/deep-equal'; import { doPreferenceSet } from 'redux/actions/sync'; @@ -47,10 +48,12 @@ export const buildSharedStateMiddleware = ( if (sharedStateCb) { // Pass dispatch to the callback to consumers can dispatch actions in response to preference set - sharedStateCb({ dispatch, getState }); + sharedStateCb({ dispatch, getState, syncId: timeout }); } clearTimeout(timeout); return actionResult; } + timeout = setTimeout(runPreferences, RUN_PREFERENCES_DELAY_MS); + dispatch({ type: ACTIONS.SHARED_STATE_SYNC_ID_CHANGED, data: timeout }); }; diff --git a/ui/redux/reducers/sync.js b/ui/redux/reducers/sync.js index 0e0e55a93..1547f4d94 100644 --- a/ui/redux/reducers/sync.js +++ b/ui/redux/reducers/sync.js @@ -16,6 +16,7 @@ const defaultState: SyncState = { setSyncIsPending: false, prefsReady: false, syncLocked: false, + sharedStateSyncId: -1, hashChanged: false, fatalError: false, }; @@ -114,6 +115,10 @@ reducers[ACTIONS.SYNC_FATAL_ERROR] = (state: SyncState) => { reducers[ACTIONS.SYNC_RESET] = () => defaultState; +reducers[ACTIONS.SHARED_STATE_SYNC_ID_CHANGED] = (state: SyncState, action: any) => { + return { ...state, sharedStateSyncId: action.data }; +}; + export default function syncReducer(state: SyncState = defaultState, action: any) { const handler = reducers[action.type]; if (handler) return handler(state, action); diff --git a/ui/store.js b/ui/store.js index 13f9e24a8..a26c57905 100644 --- a/ui/store.js +++ b/ui/store.js @@ -185,8 +185,8 @@ const sharedStateFilters = { unpublishedCollections: { source: 'collections', property: 'unpublished' }, }; -const sharedStateCb = ({ dispatch, getState }) => { - dispatch(doSyncLoop()); +const sharedStateCb = ({ dispatch, getState, syncId }) => { + dispatch(doSyncLoop(false, syncId)); }; const populateAuthTokenHeader = () => {