Почему мы убрали оптимистичные апдейты из планировщика
Мы два года считали, что мгновенная реакция интерфейса — это по определению хорошо. Потом посчитали, как часто эта мгновенная реакция оказывается неправдой, и убрали её из переноса встреч.
Как это было устроено
Перенос встречи в календаре — перетаскивание карточки на другой слот. Обработчик применял изменение к локальному состоянию сразу, а запрос уходил следом. Классика:
function moveMeeting(id: MeetingId, target: Slot): void {
const before = store.meeting(id).slot;
// Мгновенно: карточка уже на новом месте.
store.apply({ type: 'meeting/moved', id, slot: target });
api.move(id, target).catch((err) => {
// Через 300–900 мс: карточка прыгает обратно.
store.apply({ type: 'meeting/moved', id, slot: before });
toast(describe(err));
});
}
Пока сервер отвечал «да», всё было прекрасно. Вопрос в том, как часто он отвечал «нет».
Четыре процента — это много
Мы добавили метрику на исход операции и собрали данные за март–апрель. Из 12 400 переносов встреч 510 (4,1%) закончились откатом. Разбивка по причинам:
| Причина отказа | Доля откатов | Событий |
|---|---|---|
| Переговорка занята (конфликт брони) | 61% | 311 |
| Встречу параллельно правил кто-то другой | 18% | 92 |
| Нет прав на чужую повторяющуюся встречу | 13% | 66 |
| Сеть и таймауты | 8% | 41 |
Четыре процента звучит немного, пока не переведёшь в людей: это примерно каждый двадцать пятый перенос и 23 обращения в поддержку за квартал с формулировкой вида «я перенёс встречу, а она вернулась обратно, и я не понял, сохранилось или нет». Причём главная причина — конфликт брони — принципиально не предсказуема на клиенте: комнату мог занять другой человек полсекунды назад.
Три отката, которые не получилось сделать незаметными
Мы честно пытались спасти оптимизм. Не вышло по трём причинам, и каждая из них — не про анимацию, а про смысл.
- Откат виден, потому что он поздний. p95 ответа сервера на тот момент был 740 мс. За это время человек успевает перевести взгляд, начать следующее действие и не связать прыжок карточки со своим прошлым жестом. Мы пробовали подсветку и анимацию возврата — стало нарядно, но не понятнее.
- Откат ломает соседние действия. Перетащил встречу на 15:00, сразу перетащил вторую на 16:00, первая откатилась — и вторая теперь стоит относительно состояния, которого нет. Мы завели стек снимков и корректно раскручивали его в обратном порядке; кода стало заметно больше, а число веток, которые надо тестировать, выросло примерно вдвое.
- Оптимизм врал не только владельцу. Карточка «переехала» в интерфейсе того, кто тянул, но у остальных участников встречи ничего не менялось — они видели правду. То есть на пару секунд у двух людей на соседних мониторах был разный календарь, и правым оказывался тот, кто ничего не делал.
Что сделали вместо
Состояние операции стало явным и конечным. Никаких снимков и раскруток — три варианта и всё:
type MoveState =
| { kind: 'idle' }
| { kind: 'pending'; from: Slot; to: Slot; token: string }
| { kind: 'failed'; from: Slot; reason: MoveError };
// Индикатор занятости показываем не сразу: если сервер ответил
// за 120 мс, мерцание спиннера раздражает сильнее ожидания.
const SPINNER_DELAY_MS = 120;
async function moveMeeting(id: MeetingId, target: Slot): Promise<void> {
const from = store.meeting(id).slot;
// Один и тот же token при повторе — сервер не создаст вторую правку.
const token = idempotencyToken(id, from, target);
store.setMove(id, { kind: 'pending', from, to: target, token });
const spinner = setTimeout(() => store.showBusy(id), SPINNER_DELAY_MS);
try {
const applied = await api.move(id, target, token);
// Пишем то, что вернул сервер, а не то, что мы просили.
store.commit(applied);
} catch (err) {
store.setMove(id, { kind: 'failed', from, reason: toMoveError(err) });
} finally {
clearTimeout(spinner);
}
}
Пока операция в pending, целевой слот подсвечен, карточка остаётся на старом месте, а повторное перетаскивание той же встречи заблокировано. При failed мы показываем не тост, а сообщение прямо на карточке — с причиной и, для самого частого случая, с двумя ближайшими свободными слотами этой комнаты.
Пришлось чинить сервер
Честное ожидание работает только тогда, когда ждать недолго. С p95 в 740 мс новый вариант ощущался бы хуже старого, поэтому половина работы была не во фронтенде.
- Убрали из обработчика переноса синхронную рассылку уведомлений — теперь она уходит в очередь, о которой мы писали в мае. Минус 210 мс.
- Схлопнули три последовательных запроса к базе (встреча, участники, бронь) в один с двумя
join. Минус 160 мс. - Проверку конфликта отдали ограничению исключения в PostgreSQL вместо чтения-проверки-записи — заодно ушла гонка, из-за которой возникали 18% откатов из таблицы выше. Об этом есть отдельная заметка.
Итог: p95 переноса 240 мс, p99 — 410 мс. На этих числах ожидание перестаёт читаться как задержка.
Где оптимизм остался
Мы не выбрасывали подход целиком — это был бы карго-культ наоборот. Оптимистичный апдейт остался там, где он безопасен по устройству операции:
- Отметка задачи выполненной. Отказать может только сеть; конфликтов по смыслу нет, повторная отметка идемпотентна.
- Раскрытие и сворачивание блоков, локальные фильтры, порядок колонок. Это состояние интерфейса, серверу оно вообще не принадлежит.
- Черновик описания встречи. Текст в поле — уже правда, вопрос только в моменте сохранения.
Правило, которое мы из этого вывели и записали в свод соглашений команды: оптимистично применяем только те операции, отказ по которым не зависит от состояния на сервере. Если сервер может сказать «нет» по причине, о которой клиент не мог знать, — ждём ответ.
Что изменилось в цифрах
| Метрика | До | После |
|---|---|---|
| p95 операции переноса | 740 мс | 240 мс |
| Видимых откатов на 1 000 переносов | 41 | 0 |
| Обращений «встреча вернулась назад» | 23 за квартал | 0 за квартал |
| Повторных попыток переноса подряд | 7,8% | 2,1% |
| Строк кода в модуле переноса | 1 180 | 780 |
Последняя строка — приятный побочный эффект: исчезли снимки состояния, стек отката и ветки «а что если откатилось то, поверх чего мы уже применили другое». Код стал не только короче, но и понятнее в чтении, что для модуля с двумя дежурными правками в квартал важнее пары сэкономленных килобайт.