Проверка оптимистичных обновлений iOS на облачном Mac

Проверка оптимистичных обновлений iOS на облачном Mac

Проверка оптимистичных обновлений iOS на облачном Mac

Кнопка избранного меняет цвет сразу после нажатия, но при сбое сети откатывается в неверное состояние. Пользователь выходит из аккаунта и входит снова, а старый запрос меняет интерфейс уже нового пользователя. Чтобы воспроизвести такие ошибки, не нужно ждать удачного стечения обстоятельств на медленном соединении. Достаточно выделить оптимистичное обновление в отдельный автомат состояний и в задании сборки на облачном Mac подать ему события успеха, отказа и смены сессии в нужном порядке.

Сначала задайте правило: не более одной незавершённой записи

В этой статье используется стратегия, которую проще всего проверить: для одной записи допускается не более одного незавершённого запроса на изменение. Повторное нажатие во время запроса не отправляет новый запрос; интерфейс сохраняет текущее временное значение и показывает, что идёт сохранение. Быстро переключать состояние несколько раз подряд не получится, зато цель отката будет однозначной.

Состояние состоит из трёх частей: подтверждённого значения, текущего отображаемого значения и токена незавершённого запроса. Начало операции меняет только отображаемое значение. При успехе оба значения обновляются по ответу сервера, а при однозначном отказе отображение возвращается к подтверждённому значению. Каждая запись списка хранит собственное состояние; общий булев флаг для всего списка не используется.

Оптимистичное обновление лишь заранее показывает намерение пользователя, но не доказывает, что данные уже сохранены. Клиентский токен также не заменяет управление порядком записи на сервере.

Отделите случаи с неизвестным результатом

Тайм-аут или разрыв соединения не означают, что сервер отклонил операцию. В таком случае можно убрать временную обратную связь, но нужно перейти в состояние ожидания синхронизации и разрешить новые действия только после повторного чтения данных с сервера. Ветка отказа ниже предназначена лишь для отклонений по бизнес-правилам, при которых достоверно известно, что изменение не было зафиксировано. Она не предназначена для непосредственной обработки любых сетевых ошибок.

Реализуйте автомат состояний без зависимости от интерфейса

Сохраните следующий код в optimistic-check.swift. Для токенов используются отдельные идентификаторы, которые не переиспользуются при смене сессии. Сама модель не запускает сетевые операции и не зависит от UI-фреймворков, поэтому её можно выполнить непосредственно на узле с установленными инструментами командной строки.

import Foundation

struct FavoriteState {
    private(set) var confirmed: Bool
    private(set) var visible: Bool
    private(set) var pending: UUID?

    init(_ value: Bool) {
        confirmed = value
        visible = value
    }

    mutating func begin(_ desired: Bool) -> UUID? {
        guard pending == nil else { return nil }
        let ticket = UUID()
        pending = ticket
        visible = desired
        return ticket
    }

    mutating func finish(_ ticket: UUID, saved: Bool?) {
        guard pending == ticket else { return }
        if let saved {
            confirmed = saved
        }
        visible = confirmed
        pending = nil
    }

    mutating func replaceSession(with value: Bool) {
        pending = nil
        confirmed = value
        visible = value
    }
}

Опциональное значение в finish имеет строго определённый смысл: переданное значение — это итоговое состояние, подтверждённое сервером, а nil означает, что изменение заведомо не было зафиксировано. Сервер может заменить запрошенное значение другим в соответствии с бизнес-правилами, поэтому при успехе нельзя просто скопировать целевое значение, заданное нажатием пользователя.

В рабочем коде разместите автомат состояний на главном акторе и после завершения сетевой операции возвращайтесь на тот же актор перед изменением состояния. Не вызывайте эти методы одновременно из двух очередей обратных вызовов: тип-значение сам по себе не устраняет гонки данных.

Проверяйте порядок событий вместо ожидания реальной сети

Добавьте следующие проверки в конец того же файла. Для них не нужны ни задержки, ни настоящие запросы. Хотя содержимое токенов случайно, проверки зависят только от их равенства, поэтому результат остаётся детерминированным.

var state = FavoriteState(false)

let first = state.begin(true)!
precondition(state.visible && !state.confirmed)
precondition(state.begin(false) == nil)
state.finish(first, saved: nil)
precondition(!state.visible && state.pending == nil)

let second = state.begin(true)!
state.finish(second, saved: true)
precondition(state.visible && state.confirmed)
state.finish(second, saved: false)
precondition(state.visible)

let oldSession = state.begin(false)!
state.replaceSession(with: false)
let current = state.begin(true)!
state.finish(oldSession, saved: false)
precondition(state.pending == current && state.visible)
state.finish(current, saved: false)
precondition(!state.visible && !state.confirmed)

Запустите код следующим образом. Не добавляйте при этой компиляции параметры оптимизации, отключающие проверку предусловий.

set -eu
xcrun swiftc optimistic-check.swift -o optimistic-check
./optimistic-check

Ненулевой код завершения должен останавливать задание. Первая группа проверяет откат при отказе и повторные нажатия, вторая — повторный обратный вызов, третья — ответ из старой сессии и ситуацию, когда значение, возвращённое сервером, отличается от намерения пользователя. Выводить идентификаторы аккаунтов или полное тело ответа не требуется.

Сохраняйте идентичность записи при подключении сети

Реальный запрос должен захватывать идентификатор записи, идентификатор текущей сессии и токен. После получения ответа сначала убедитесь, что он по-прежнему относится к той же записи, и только затем передавайте результат соответствующему автомату состояний. При переиспользовании ячеек списка нельзя искать целевой элемент только по номеру строки: сортировка и фильтрация меняют номера строк.

При смене аккаунта нужно заменить состояния всех записей и заново загрузить данные из авторитетного источника. Метод replaceSession в примере показывает сброс после получения начального значения для новой сессии, а не разрешает продолжать использовать старое значение после выхода из аккаунта.

Отмена запроса лишь сокращает ненужную работу и не заменяет проверку токена. К моменту отмены сервер уже мог зафиксировать изменение, а обратный вызов — оказаться в очереди на выполнение. Если одну запись одновременно меняют с нескольких устройств, сервер также должен поддерживать запись с условием по версии либо возвращать результат, указывающий на конфликт. Локальное ограничение одним незавершённым запросом не решает проблему конкуренции между устройствами.

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

Перед слиянием кода добавьте три вида проверок: после отказа по бизнес-правилам кнопкой снова можно пользоваться; при неизвестном результате новые записи запрещаются и запускается синхронизация; после смены аккаунта старый обратный вызов не меняет новое состояние. Помимо проверок автомата состояний, UI-тестами подтвердите, что индикатор сохранения, состояние для средств универсального доступа и блокировка нажатий действительно привязаны к pending.

Обнаружив ошибку отката, сначала зафиксируйте порядок событий, обезличенный идентификатор записи и категорию результата, затем проверьте, не используются ли старые номера строк или модели из другой сессии. Не маскируйте проблему увеличением времени ожидания. Итоговый критерий приёмки — у каждого изменения состояния есть событие-основание, а не просто то, что кнопка в конце случайно выглядит правильно.

Часто задаваемые вопросы

Означает ли тайм-аут, что сервер ничего не сохранил?

Нет. Результат неизвестен. Временное изменение интерфейса можно отменить, но перед новой записью следует заново получить состояние сервера.

Защищает ли локальный идентификатор от гонок на сервере?

Нет. Он блокирует только устаревшие обновления интерфейса. Для сервера нужны последовательные записи или проверка версии.

Добавьте физический Mac в CI/CD-пайплайн

Выберите Apple Silicon M4 или M4 Pro: вся машина в вашем распоряжении, без виртуализации и совместного использования. Подберите модель под нагрузку сборки и удобный для команды период оплаты.

Арендовать физический узел

Передайте следующую сборку выделенному узлу

Сначала выберите конфигурацию и период оплаты, затем подключите к работе команды выделенный физический Mac.