Обработка race conditions

В асинхронной валидации форм основная проблема заключается не в самой логике проверки, а в порядке завершения операций. Любая задержка — сетевой запрос, таймер, сложная вычислительная функция — способна привести к состоянию, когда результат устарел к моменту его применения. Это и есть типичный race condition: несколько асинхронных операций конкурируют за право обновить состояние, и последняя запущенная не обязательно завершится последней.

В контексте Vest эта проблема проявляется особенно часто, поскольку библиотека ориентирована на декларативное описание правил, многие из которых могут быть асинхронными: проверка уникальности логина, доступности email, валидности промокода через API и т.д.


Асинхронная валидация формально выглядит просто: поле изменилось → запустилась проверка → результат применился. Однако в реальности цепочка нарушается из-за перекрытия запросов.

Типичный сценарий:

  1. Пользователь вводит значение A
  2. Запускается проверка validate(A) (медленный запрос)
  3. Пользователь быстро вводит B
  4. Запускается validate(B)
  5. validate(B) завершается быстрее и применяет результат
  6. Позже завершается validate(A) и перезаписывает состояние

Итог: интерфейс показывает результат для устаревшего значения.


Почему проблема усиливается в Vest

В Vest каждая проверка формируется как набор тестов, которые могут выполняться:

  • последовательно
  • параллельно
  • асинхронно (Promise-based)

При этом библиотека гарантирует корректность результата набора правил, но не гарантирует актуальность внешних данных, если разработчик не учитывает конкуренцию запросов.

Особенно риск возрастает в случаях:

  • серверной валидации (API calls)
  • debounce-отсутствия
  • высокой частоты изменений input
  • сложных cross-field правил

Основные стратегии устранения race conditions

Версионирование запросов

Один из наиболее надежных подходов — введение монотонного счётчика версий.

Идея: каждый запуск валидации получает свой versionId. Результат применяется только если версия совпадает с текущей.

let currentVersion = 0;

function validateUsername(value) {
  const version = ++currentVersion;

  return fetch(`/api/validate?username=${value}`)
    .then(r => r.json())
    .then(result => {
      if (version !== currentVersion) return null;
      return result;
    });
}

Такой подход полностью отсеивает устаревшие ответы, даже если они завершились позже.


Отмена предыдущих запросов (AbortController)

Более корректный с точки зрения ресурсов способ — отмена предыдущих асинхронных операций.

let controller;

function validateEmail(value) {
  if (controller) {
    controller.abort();
  }

  controller = new AbortController();

  return fetch(`/api/check-email?value=${value}`, {
    signal: controller.signal
  })
    .then(r => r.json())
    .catch(err => {
      if (err.name === "AbortError") return;
      throw err;
    });
}

Преимущество: не тратятся ресурсы на завершение ненужных запросов.


Последовательная очередь выполнения

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

let chain = Promise.resolve();

function runValidation(task) {
  chain = chain.then(() => task());
  return chain;
}

Минус этого подхода — рост задержки при быстром вводе, но зато полная предсказуемость.


Игнорирование устаревших результатов на уровне состояния

В системах, где результат валидации хранится отдельно, можно проверять актуальность значения прямо перед применением:

function applyResult(valueAtStart, currentValue, result) {
  if (valueAtStart !== currentValue) return;
  setValidationResult(result);
}

Этот подход особенно эффективен в UI-фреймворках с реактивным состоянием.


Интеграция с асинхронными тестами Vest

В Vest асинхронные проверки часто выглядят так:

import { test } from "vest";

test("username", "check availability", async () => {
  const res = await fetch(`/api/user/${value}`);
  const data = await res.json();

  if (!data.available) {
    fail("Username is taken");
  }
});

Проблема возникает, когда value изменяется между запуском теста и завершением fetch.

Чтобы минимизировать race condition, вводится контроль актуальности внутри теста:

let version = 0;

test("username", "check availability", async () => {
  const myVersion = version;

  const res = await fetch(`/api/user/${value}`);
  const data = await res.json();

  if (myVersion !== version) return;

  if (!data.available) {
    fail("Username is taken");
  }
});

Debounce как первый уровень защиты

Хотя debounce не решает race condition полностью, он резко снижает его вероятность, уменьшая количество конкурентных запросов.

function debounce(fn, delay) {
  let timer;

  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

Использование:

const validateDebounced = debounce(validateEmail, 300);

В связке с Vest debounce часто применяется на уровне UI перед запуском тестов.


Комбинированные стратегии

На практике одна мера редко достаточна. Надежная система обычно сочетает:

  • версионирование (основной механизм защиты)
  • AbortController (оптимизация ресурсов)
  • debounce (снижение частоты)
  • проверку актуальности состояния (финальный барьер)

Такой слой защиты устраняет как визуальные баги, так и лишние сетевые операции.


Race condition в cross-field валидации

Особенно сложный случай — зависимые поля:

  • пароль
  • подтверждение пароля
  • условия согласования данных

Если одно поле триггерит валидацию другого, легко получить перекрёстные гонки обновлений.

Пример проблемы:

  1. меняется password
  2. запускается validate(confirmPassword)
  3. меняется confirmPassword
  4. запускается новая проверка
  5. старая проверка завершается позже и перетирает результат

Решение — централизованная синхронизация версии формы:

let formVersion = 0;

function updateField() {
  formVersion++;
}

function validateCrossField() {
  const localVersion = formVersion;

  return async () => {
    const result = await runValidation();

    if (localVersion !== formVersion) return;

    apply(result);
  };
}

Состояние гонки и реактивные системы

В реактивных UI подходах проблема усиливается тем, что рендер может происходить независимо от завершения асинхронных задач. Поэтому любое внешнее состояние должно иметь:

  • источник истины (single source of truth)
  • контроль актуальности результата
  • изоляцию побочных эффектов

Vest частично решает эту задачу через группировку тестов и пересчёт состояния при каждом изменении input, но ответственность за гонки в асинхронных эффектах остаётся на разработчике.


Практическая модель устойчивой асинхронной валидации

Устойчивый паттерн обычно строится так:

  1. фиксируется входное значение
  2. увеличивается версия
  3. запускается асинхронный тест
  4. результат проверяется на актуальность
  5. применяется только валидный результат
let version = 0;

async function safeValidate(value) {
  const local = ++version;

  const result = await asyncCheck(value);

  if (local !== version) return null;

  return result;
}

Этот минимальный шаблон покрывает большую часть реальных race condition сценариев в формах.


Итоговая инженерная логика

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