Обработка конфликтов

Конфликт в TanStack Query возникает в момент, когда несколько операций одновременно изменяют или используют одни и те же данные. На практике это проявляется в нескольких сценариях:

  • два пользователя изменяют одну запись;
  • несколько мутаций отправляются подряд;
  • оптимистичное обновление расходится с ответом сервера;
  • старый запрос перезаписывает новые данные;
  • фоновые refetch-запросы возвращают устаревшее состояние;
  • кэш обновляется до завершения предыдущей мутации.

Поскольку TanStack Query работает с асинхронным серверным состоянием, проблема конфликтов является частью архитектуры приложения, а не редким исключением.


Типы конфликтов

Конфликт между оптимистичным обновлением и сервером

Наиболее распространённый случай:

  1. пользователь изменяет данные;
  2. интерфейс мгновенно обновляется;
  3. сервер возвращает другое состояние;
  4. кэш начинает расходиться с UI.

Пример:

queryClient.setQueryData(['todo', id], old => ({
  ...old,
  completed: true
}))

Если сервер отклонит изменение или изменит дополнительные поля, локальный кэш станет неконсистентным.


Конфликт параллельных мутаций

Несколько мутаций могут выполняться одновременно:

mutationA.mutate(dataA)
mutationB.mutate(dataB)

Если обе мутации обновляют один query cache, результат зависит от порядка завершения запросов, а не от порядка вызова.

Это создаёт condition race.


Конфликт устаревших запросов

Сценарий:

  1. выполняется refetch;
  2. пользователь меняет данные;
  3. мутация успешно завершилась;
  4. старый refetch возвращает старое состояние;
  5. кэш перезаписывается устаревшими данными.

Такой конфликт особенно заметен при медленном API.


Конфликт pagination и invalidateQueries

После invalidateQueries несколько страниц могут обновляться независимо:

queryClient.invalidateQueries({
  queryKey: ['posts']
})

Если страницы обновляются в разное время, часть интерфейса может содержать новые данные, а часть — старые.


Причины возникновения конфликтов

Асинхронная природа HTTP

HTTP-запросы не гарантируют порядок завершения.

Более поздний запрос может завершиться раньше предыдущего:

Request A ---> 900ms
Request B ---> 200ms

Результат:

B completed first
A overwrote cache later

Отсутствие транзакций на клиенте

TanStack Query не является transactional state manager.

Он не синхронизирует изменения автоматически между:

  • вкладками;
  • пользователями;
  • websocket-событиями;
  • REST API;
  • локальными изменениями.

Неконтролируемые invalidateQueries

Чрезмерное использование invalidation приводит к хаотическим refetch:

onSuccess: () => {
  queryClient.invalidateQueries()
}

Подобный код способен вызвать лавину обновлений.


Отмена запросов как механизм предотвращения конфликтов

cancelQueries

Перед оптимистичным обновлением рекомендуется отменять активные запросы:

await queryClient.cancelQueries({
  queryKey: ['todos']
})

Это предотвращает перезапись локальных изменений старыми ответами.


Типичный шаблон

const mutation = useMutation({
  mutationFn: updateTodo,

  onMutate: async updatedTodo => {
    await queryClient.cancelQueries({
      queryKey: ['todos']
    })

    const previousTodos =
      queryClient.getQueryData(['todos'])

    queryClient.setQueryData(
      ['todos'],
      old => old.map(todo =>
        todo.id === updatedTodo.id
          ? { ...todo, ...updatedTodo }
          : todo
      )
    )

    return { previousTodos }
  },

  onError: (err, variables, context) => {
    queryClient.setQueryData(
      ['todos'],
      context.previousTodos
    )
  },

  onSettled: () => {
    queryClient.invalidateQueries({
      queryKey: ['todos']
    })
  }
})

Snapshot rollback

Идея rollback

Перед изменением кэша создаётся snapshot предыдущего состояния:

const previousData =
  queryClient.getQueryData(['todos'])

При ошибке выполняется откат:

queryClient.setQueryData(
  ['todos'],
  previousData
)

Проблемы rollback

Rollback становится сложным, если:

  • мутаций несколько;
  • данные вложенные;
  • обновление частичное;
  • сервер изменяет структуру ответа;
  • параллельные изменения уже успели попасть в кэш.

Конфликты optimistic updates

Последовательность optimistic updates

Проблемный сценарий:

Mutation A optimistic update
Mutation B optimistic update
Mutation B success
Mutation A rollback

Rollback первой мутации уничтожит результат второй.


Изоляция optimistic context

Для каждой мутации необходимо хранить собственный snapshot:

onMutate: async newTodo => {
  const previous =
    queryClient.getQueryData(['todos'])

  return { previous }
}

Контекст мутации должен быть независимым.


Стратегии разрешения конфликтов

Last write wins

Самая простая стратегия.

Последнее изменение перезаписывает предыдущие:

queryClient.setQueryData(key, newData)

Преимущества:

  • простота;
  • минимальная логика;
  • высокая производительность.

Недостатки:

  • потеря изменений;
  • возможная неконсистентность.

Server authority

Сервер считается единственным источником истины.

После каждой мутации выполняется refetch:

onSuccess: () => {
  queryClient.invalidateQueries({
    queryKey: ['todos']
  })
}

Преимущества:

  • высокая согласованность;
  • отсутствие локального рассинхрона.

Недостатки:

  • дополнительная нагрузка;
  • мерцание интерфейса;
  • задержки.

Merge strategy

Локальные и серверные изменения объединяются:

queryClient.setQueryData(
  ['profile'],
  old => ({
    ...old,
    ...serverData
  })
)

Подход полезен для:

  • профилей;
  • настроек;
  • частичных PATCH-операций.

Version-based conflict resolution

Каждая сущность содержит версию:

{
  id: 1,
  title: 'Post',
  version: 5
}

Перед обновлением сервер проверяет version.

Если версия изменилась:

409 Conflict

Клиент может:

  • перезагрузить данные;
  • показать предупреждение;
  • выполнить merge;
  • повторить запрос.

Обработка HTTP 409 Conflict

Обнаружение конфликта

const mutation = useMutation({
  mutationFn: updatePost,

  onError: error => {
    if (error.response?.status === 409) {
      console.log('Conflict detected')
    }
  }
})

Повторная синхронизация

После 409 обычно выполняется refetch:

await queryClient.invalidateQueries({
  queryKey: ['post', id]
})

Получение актуального состояния

Некоторые API возвращают актуальные данные прямо в 409 response:

{
  "message": "Conflict",
  "currentData": {
    "title": "Updated"
  }
}

Тогда можно обновить кэш напрямую:

queryClient.setQueryData(
  ['post', id],
  error.response.data.currentData
)

Serial execution мутаций

Очередь мутаций

Иногда мутации необходимо выполнять строго последовательно.

Пример:

await mutation.mutateAsync(step1)
await mutation.mutateAsync(step2)
await mutation.mutateAsync(step3)

Это предотвращает гонки состояния.


Mutex-подход

Можно блокировать повторную мутацию:

if (mutation.isPending) {
  return
}

disable UI during mutation

<button disabled={mutation.isPending}>
  Save
</button>

Это уменьшает вероятность конфликтов пользовательского ввода.


Дедупликация запросов

TanStack Query автоматически объединяет одинаковые запросы.

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos
})

Если несколько компонентов одновременно используют одинаковый queryKey, будет выполнен один HTTP-запрос.

Это снижает вероятность конфликтов refetch.


staleTime как инструмент снижения конфликтов

Частые refetch увеличивают вероятность race conditions.

Настройка staleTime уменьшает количество фоновых обновлений:

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 60000
})

refetchOnWindowFocus и конфликты

Автоматический refetch при фокусе окна может неожиданно перезаписать optimistic state.

useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  refetchOnWindowFocus: false
})

Конфликты infinite queries

Частичная рассинхронизация страниц

Infinite query хранит массив страниц:

data.pages

Если обновить только одну страницу:

queryClient.setQueryData(
  ['feed'],
  old => ({
    ...old,
    pages: updatedPages
  })
)

остальные страницы могут содержать старые сущности.


Дублирование элементов

При cursor pagination после refetch могут появляться дубликаты:

Page 1 -> item 10
Page 2 -> item 10

Необходима нормализация данных:

const unique = Array.from(
  new Map(items.map(i => [i.id, i])).values()
)

Конфликты websocket и query cache

Реальное время против локального состояния

Сервер может прислать websocket update во время optimistic mutation.

Проблема:

optimistic update
websocket update
mutation response

Каждый источник пытается обновить cache.


Сравнение timestamps

Для разрешения конфликта используют timestamps:

if (incoming.updatedAt > current.updatedAt) {
  return incoming
}

Конфликты между вкладками

Несколько вкладок браузера имеют независимый query cache.

Проблемы:

  • устаревшие данные;
  • дублирующиеся refetch;
  • разные optimistic states.

broadcastQueryClient

Плагин синхронизации вкладок:

broadcastQueryClient({
  queryClient,
  broadcastChannel: 'app-cache'
})

Изменения query cache будут распространяться между вкладками.


Стратегии invalidation

Грубая invalidation

queryClient.invalidateQueries()

Минусы:

  • лишние refetch;
  • каскад конфликтов;
  • нагрузка на API.

Точная invalidation

queryClient.invalidateQueries({
  queryKey: ['todos', id]
})

Предпочтительнее инвалидировать только затронутые сущности.


Обновление кэша вместо invalidateQueries

Вместо полного refetch можно обновлять кэш вручную:

queryClient.setQueryData(
  ['todo', id],
  updatedTodo
)

Преимущества:

  • отсутствие лишнего network traffic;
  • меньше конфликтов;
  • мгновенный UI.

Недостаток — усложнение логики синхронизации.


Atomic cache updates

Неизменяемые обновления

Опасный код:

old.title = 'New'
return old

Безопасный вариант:

return {
  ...old,
  title: 'New'
}

Mutation-in-place может привести к скрытым конфликтам и некорректным рендерам.


Согласованность связанных query

Одна сущность часто хранится в нескольких query:

['todos']
['todo', id]
['dashboard']

При обновлении только одного query возникает рассинхрон.


Синхронное обновление нескольких query

queryClient.setQueryData(
  ['todo', id],
  updatedTodo
)

queryClient.setQueryData(
  ['todos'],
  old => old.map(todo =>
    todo.id === id
      ? updatedTodo
      : todo
  )
)

Retry и конфликты

Автоматический retry способен усугубить проблему.

useMutation({
  mutationFn: saveData,
  retry: 3
})

Если сервер уже частично обработал запрос, повторная отправка может создать повторное изменение.


Идемпотентность мутаций

Безопаснее использовать idempotent API:

PUT /resource/1

вместо:

POST /resource

Логирование конфликтов

Для сложных систем полезно логировать:

  • время мутации;
  • queryKey;
  • snapshot;
  • server response;
  • rollback;
  • retry;
  • invalidateQueries.

Пример:

onError: (error, vars, ctx) => {
  console.log({
    error,
    vars,
    previous: ctx.previous
  })
}

Архитектурные подходы

Нормализованный кэш

Большие приложения часто переходят к entity normalization:

{
  entities: {
    todos: {
      1: {...},
      2: {...}
    }
  }
}

Это уменьшает вероятность конфликтов между query.


Event sourcing подход

Вместо немедленного обновления состояния фиксируются события:

TODO_UPDATED
TODO_COMPLETED
TODO_REMOVED

После этого вычисляется итоговое состояние.

Подход сложнее, но лучше масштабируется в real-time системах.


Практический шаблон безопасной мутации

const mutation = useMutation({
  mutationFn: updateTodo,

  onMutate: async variables => {
    await queryClient.cancelQueries({
      queryKey: ['todos']
    })

    const previous =
      queryClient.getQueryData(['todos'])

    queryClient.setQueryData(
      ['todos'],
      old => old.map(todo =>
        todo.id === variables.id
          ? { ...todo, ...variables }
          : todo
      )
    )

    return { previous }
  },

  onError: (error, variables, context) => {
    queryClient.setQueryData(
      ['todos'],
      context.previous
    )
  },

  onSuccess: serverTodo => {
    queryClient.setQueryData(
      ['todo', serverTodo.id],
      serverTodo
    )
  },

  onSettled: () => {
    queryClient.invalidateQueries({
      queryKey: ['todos']
    })
  }
})

В этом шаблоне объединены:

  • отмена запросов;
  • optimistic update;
  • rollback;
  • точечное обновление cache;
  • последующая синхронизация с сервером.