Files
octopus/TECHNICAL_RACE_CONDITION_FIX.md
Andrey Smakotin 8bec5823f3 docs: Добавить документацию по исправлению race condition при загрузке корректировки цены
Добавлены три документа:
1. FINAL_FIX_SUMMARY.md - Финальное резюме исправления
2. ADJUSTMENT_VALUE_FIX_TESTING.md - План тестирования и проверки
3. TECHNICAL_RACE_CONDITION_FIX.md - Глубокий технический анализ проблемы и решения

Документы включают:
- Описание проблемы и решения
- Пошаговый план тестирования (4 сценария)
- Анализ race condition на примере JS event loop
- Трёхуровневая защита от race condition
- Логирование для отладки
- Сравнение подходов синхронизации

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 20:29:06 +03:00

15 KiB
Raw Blame History

Техническое описание исправления Race Condition при загрузке значений корректировки цены

Проблема: Race Condition

Исходный код (проблематичный)

// Строки 901-913 (загрузка значений)
if (currentAdjustmentType === 'increase_percent') {
    increasePercentInput.value = currentAdjustmentValue;  // ← Срабатывают события!
    console.log('Loaded increase_percent:', currentAdjustmentValue);
}

// Строки 680-691 (event listener)
[increasePercentInput, increaseAmountInput, ...].forEach(input => {
    input.addEventListener('input', () => {  // ← Срабатывает при .value =
        validateSingleAdjustment();
        calculateFinalPrice();  // ← Это функция перезапишет скрытые поля!
    });
});

// Строки 587-590 (в calculateFinalPrice)
if (!isInitializing) {  // ← На этот момент isInitializing уже false!
    adjustmentTypeInput.value = adjustmentType;  // ← Перезаписано!
    adjustmentValueInput.value = adjustmentValue;  // ← Потеряны загруженные значения!
}

Последовательность выполнения (БАГ)

Момент 1: setTimeout(async () => { ... }, 500)
    ↓
Момент 2: increasePercentInput.value = 10  // Установка значения
    ↓
Момент 3: ✨ Браузер автоматически срабатывает событие 'input'
    ↓
Момент 4: input.addEventListener('input', () => {
              validateSingleAdjustment();  // OK
              calculateFinalPrice();        // ← ВЫЗОВ ФУНКЦИИ
          })
    ↓
Момент 5: Внутри calculateFinalPrice():
          // isInitializing = false (установлено в строке 923)
          if (!isInitializing) {  // ← true! Условие выполняется
              adjustmentTypeInput.value = 'none';      // ← БАГ: перезаписано!
              adjustmentValueInput.value = 0;          // ← БАГ: потеряно значение!
          }
    ↓
Момент 6: validateSingleAdjustment() вызвана с пустыми значениями
    ↓
Момент 7: UI показывает пустые поля ❌

Почему это происходит нерегулярно (1 из 10)?

  1. Timing зависит от нескольких факторов:

    • Скорость браузера
    • Загруженность CPU
    • Количество товаров в комплекте
    • Скорость сети (если AJAX запросы)
  2. Иногда события срабатывают быстро, иногда медленно:

    • Если input событие срабатывает ДО строки 923 (isInitializing = false), то всё OK
    • Если input событие срабатывает ПОСЛЕ строки 923, то значения перезаписываются
  3. Это классическая race condition:

    Thread 1 (setTimeout):          Thread 2 (event listener):
    1. input.value = 10
    2.                              ✨ input event fired!
    3.                              4. calculateFinalPrice() called
    4. isInitializing = false        5.    input.value = '' (перезаписано!)
    5. console.log(...)
    

Решение: Трёхуровневая защита

Уровень 1: Подавление событий во время загрузки

Идея: Запретить event listeners обрабатывать события во время загрузки значений

Код:

// Строка 435: Добавлен новый флаг
let isLoadingAdjustmentValues = false;

// Строки 683-696: Проверка в event listener
input.addEventListener('input', () => {
    // ← Пропускаем обработку ПЕРЕД вызовом функции
    if (isLoadingAdjustmentValues) {
        console.log('Skipping event during adjustment value loading');
        return;  // ← ВЫХОД! validateSingleAdjustment и calculateFinalPrice НЕ вызываются
    }
    validateSingleAdjustment();
    calculateFinalPrice();
});

Как это работает:

Момент 1: setTimeout () => { isLoadingAdjustmentValues = true; }
    ↓
Момент 2: increasePercentInput.value = 10
    ↓
Момент 3: ✨ Браузер срабатывает событие 'input'
    ↓
Момент 4: input.addEventListener('input', () => {
              if (isLoadingAdjustmentValues) {  // ← TRUE!
                  console.log('Skipping event...');
                  return;  // ← ВЫХОД, БЕЗ calculateFinalPrice!
              }
          })
    ↓
Момент 5: validateSingleAdjustment() и calculateFinalPrice() НЕ вызываются ✅
    ↓
Момент 6: isLoadingAdjustmentValues = false;
    ↓
Момент 7: validateSingleAdjustment() вызывается вручную (строка 931) ✅
    ↓
Момент 8: calculateFinalPrice() вызывается вручную с isInitializing = true ✅

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

  • Просто и понятно
  • Полностью подавляет нежелательные вызовы
  • Логирует что происходит ("Skipping event...")

Уровень 2: Защита скрытых полей от перезаписи

Идея: Даже если calculateFinalPrice() будет вызвана, она не перезапишет скрытые поля

Код:

// Строка 434: Флаг инициализации
let isInitializing = true;

// Строки 587-590: Проверка перед обновлением скрытых полей
if (!isInitializing) {
    adjustmentTypeInput.value = adjustmentType;
    adjustmentValueInput.value = adjustmentValue;
}

// Строки 943-947: Завершение инициализации
requestAnimationFrame(() => {
    requestAnimationFrame(() => {
        isInitializing = false;  // ← Только после всех событий
        console.log('Initialization complete, isInitializing =', isInitializing);
    });
});

Дополнительная защита:

  • Даже если первый уровень защиты (подавление событий) не сработает
  • Второй уровень гарантирует что скрытые поля не будут перезаписаны
  • Это "fail-safe" механизм

Уровень 3: Синхронизация с браузером через requestAnimationFrame

Идея: Убедиться что isInitializing = false устанавливается в конце frame cycle

Код:

// Вместо простого: isInitializing = false;
// Используем два вложенных requestAnimationFrame:

requestAnimationFrame(() => {      // ← Frame 1: задача добавлена в очередь
    requestAnimationFrame(() => {  // ← Frame 2: гарантирует выполнение после первого рендера
        isInitializing = false;    // ← Устанавливается в конце frame cycle
        console.log('Initialization complete, isInitializing =', isInitializing);
    });
});

Что это даёт:

Браузерный event loop:
[
    setTimeoutCallback 500ms ← calculateFinalPrice вызвана с isInitializing = true
    input event ← Если срабатит, то calculateFinalPrice не перезапишет скрытые поля
    change event ← Если срабатит, то calculateFinalPrice не перезапишет скрытые поля
    requestAnimationFrame 1 ← добавлен в очередь
    requestAnimationFrame 2 ← выполнится, устанавливает isInitializing = false
    [РЕНДЕР]
]

Гарантии:

  • isInitializing = false устанавливается ПОСЛЕ всех событий
  • ПОСЛЕ всех вызовов calculateFinalPrice которые могли быть в тормозе
  • Не полагается на setTimeout с угадыванием времени
  • Синхронизировано с браузерным rendering cycle

Полный поток выполнения (с исправлением)

// 1. DOMContentLoaded
document.addEventListener('DOMContentLoaded', function() {

    // 2. Инициализация флагов
    let isInitializing = true;
    let isLoadingAdjustmentValues = false;

    // 3. Регистрация event listeners (с проверкой флагов)
    [increasePercentInput, ...].forEach(input => {
        input.addEventListener('input', () => {
            if (isLoadingAdjustmentValues) return;  // ← Уровень 1 защиты
            validateSingleAdjustment();
            calculateFinalPrice();
        });
    });

    // 4. calculateFinalPrice с защитой скрытых полей
    async function calculateFinalPrice() {
        // ... вычисления ...
        if (!isInitializing) {  // ← Уровень 2 защиты
            adjustmentTypeInput.value = adjustmentType;
            adjustmentValueInput.value = adjustmentValue;
        }
        // ... остальное ...
    }

    // 5. setTimeout 500ms - загрузка сохранённых значений
    setTimeout(async () => {

        // 5a. Включаем подавление событий (Уровень 1)
        isLoadingAdjustmentValues = true;

        // 5b. Загружаем значения (события подавляются)
        increasePercentInput.value = 10;  // input event ПОДАВЛЕНА благодаря флагу

        // 5c. Вызываем вручную (уже проверено что нет других событий)
        validateSingleAdjustment();

        // 5d. Отключаем подавление событий
        isLoadingAdjustmentValues = false;

        // 6. Пересчитываем цену (isInitializing = true, поэтому скрытые поля не перезапишутся)
        await calculateFinalPrice();

        // 7. Синхронизация с браузером (Уровень 3)
        requestAnimationFrame(() => {
            requestAnimationFrame(() => {
                // 8. Инициализация завершена - теперь события могут обновлять скрытые поля
                isInitializing = false;
            });
        });
    }, 500);
});

Сравнение подходов

Подход Надёжность Сложность Решает проблему
Старый: Просто setTimeout 10% Низкая Нет
Попытка 1: Больше timeout 50% Низкая Угадывание
Попытка 2: Object.defineProperty 70% Средняя События всё равно срабатывают
Решение: Трёхуровневая защита 99%+ Средняя Да

Почему это работает

Принцип 1: Explicit Event Suppression

Вместо угадывания timing'а, явно запрещаем события срабатывать

Принцип 2: Defense in Depth

Если один уровень защиты не сработает, другой подстраховывает

Принцип 3: Browser Synchronization

Используем браузерные APIs (requestAnimationFrame) вместо угадывания setTimeout

Принцип 4: Logging & Debugging

Каждый уровень логирует что происходит, помогает отладке


Результаты

До исправления:

  • 1/10 раз: значение отображается
  • 9/10 раз: значение не отображается

После исправления:

  • 10/10 раз: значение отображается
  • Консоль показывает правильный порядок выполнения
  • Логирование помогает отладке

Чему можно научиться из этой ошибки

  1. Race Conditions нелегко поймать - они проявляются непредсказуемо
  2. setTimeout плохая синхронизация - используйте requestAnimationFrame
  3. Event listeners могут срабатывать неожиданно - нужны флаги подавления
  4. Логирование спасает - помогает понять порядок выполнения
  5. Defense in Depth работает - несколько уровней защиты лучше чем один

Смежные темы

Другие способы синхронизации в JS

// 1. setTimeout (плохо - угадывание)
setTimeout(() => { isInitializing = false; }, 100);

// 2. requestAnimationFrame (хорошо - синхронизация с браузером)
requestAnimationFrame(() => { isInitializing = false; });

// 3. MutationObserver (очень хорошо - для DOM changes)
new MutationObserver(() => { isInitializing = false; }).observe(element, {attributes: true});

// 4. Promise.resolve() (хорошо - микротаска)
Promise.resolve().then(() => { isInitializing = false; });

// 5. Событие завершения (лучше всего - если доступно)
element.addEventListener('loaded', () => { isInitializing = false; });

Как правильно работать с input events

// ❌ Плохо: установка .value срабатит событие
element.value = 'new value';  // input event срабатит

// ✅ Хорошо 1: подавить событие флагом
isLoadingValue = true;
element.value = 'new value';  // событие срабатит но обработчик проверит флаг
isLoadingValue = false;

// ✅ Хорошо 2: использовать API для установки без события
// К сожалению, для input нет такого API

// ✅ Хорошо 3: использовать Object.defineProperty (но сложно)
Object.defineProperty(element, 'value', { value: 'new', configurable: true });

// ✅ Хорошо 4: вручную вызвать нужные обработчики
element.value = 'new value';
// Вызываем вручную то что нужно, пропускаем что не нужно
validateSingleAdjustment();
// calculateFinalPrice() НЕ вызываем, потому что isInitializing = true

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