Асинхронная работа с внешними сервисами

Долгий вызов сторонней системы не должен удерживать HTTP-запрос пользователя и транзакцию БД. Стандартный сценарий ERP разделяет принятие операции, внешний вызов и фиксацию результата.

HTTP-запрос
  -> проверить входные данные и права
  -> сохранить намерение/статус операции
  -> показать пользователю индикатор
  -> commit
       |
       v
AsyncWorker
  -> вызвать внешний сервис с timeout и ограничителями
  -> короткой транзакцией сохранить результат или ошибку
  -> после commit отправить ClientEvents
       |
       v
активная вкладка получает события через pool.do и обновляет интерфейс

Главное правило: внешний побочный эффект запускается только после commit данных, которые его породили. JDBC-соединение при этом не должно оставаться открытым на время сетевого ожидания.

Когда выполнять асинхронно

Выносите операцию в фон, если выполняется хотя бы одно условие:

  • внешний сервис может отвечать дольше комфортного времени HTTP-запроса;

  • ответ не нужен для формирования текущей страницы;

  • операция состоит из нескольких сетевых запросов;

  • пользователь может продолжать работу до получения результата;

  • требуется отдельный статус выполнения и последующее обновление карточки.

Оставляйте вызов синхронным, если без его результата нельзя принять или отклонить текущую команду, а время ответа ограничено коротким timeout. Асинхронность не исправляет отсутствующие timeout, идемпотентность или обработку ошибок — она только отделяет время выполнения от HTTP-запроса.

Стандартный пользовательский сценарий

  1. В транзакции запроса проверьте права и входные данные.

  2. Сохраните состояние pending/«в обработке» и идентификатор операции. Это источник истины для повторного открытия страницы.

  3. Захватите в локальные val/final только идентификаторы, неизменяемый request DTO и реального пользователя.

  4. Вызовите AsyncWorker.submitWithLoading(…​). Он покажет индикатор сразу, но передаст задачу executor только после commit.

  5. В worker вызовите внешний API. Не открывайте перед этим соединение с БД.

  6. Сохраните успешный результат короткой транзакцией и только после её commit отправьте ShowInfoEvent/ProcessOpenEvent.

  7. При ошибке сохраните состояние error, отправьте безопасное сообщение и обновите карточку.

  8. Индикатор должен быть скрыт при любом исходе; submitWithLoading делает это после старта задачи автоматически.

UI-события улучшают обратную связь, но не являются состоянием операции. Redis-очередь имеет TTL, может быть отключена, а пользователь может закрыть вкладку. После повторного открытия итог всегда должен восстанавливаться из БД.

Базовый вариант

Если внешний вызов выполняется первым, а БД нужна только для сохранения результата, достаточно контекста, который создаёт submitWithLoading:

val processId = process.id
val recipient = ServerContext.get().user
val recipientId = recipient.id

AsyncWorker.submitWithLoading(
    message = "Получаю данные внешней системы...",
    type = "external_catalog_load",
    user = recipient
) {
    val result = catalogApi.load(processId) // JDBC-соединение ещё не открыто

    val context = ServerContext.get()
    context.getService<ParameterService>().updateText(processId, resultParam, result.value)
    context.getService<ProcessService>().updateStatus(processId, successStatusId)
    context.invokeAfterCommit {
        ClientEvents.showInfo(recipientId, "Данные успешно получены")
        ClientEvents.openProcess(recipientId, processId)
    }
}

submitWithLoading сам отправит HideLoadingEvent. Если лямбда выбросит исключение, он также отправит ShowErrorEvent, а AsyncWorker запишет failure.

Автоматическое сообщение использует exception.message. Не передавайте наружу техническое исключение с чувствительными данными; преобразуйте его в исключение с безопасным пользовательским текстом.

Несколько фаз БД

Если request к внешней системе нужно построить из БД, обычный контекст worker может открыть соединение до сетевого вызова и удерживать его всё время ожидания. Для такого сценария разделяйте фазы:

withContext { прочитать и собрать неизменяемый request DTO }
                     -> контекст закрыт
вызвать внешний API -> JDBC-соединений нет
withContext { сохранить response и статус }
                     -> commit и закрытие

Запускайте оболочку через submitCancellable(type, user, inContext = false, task) после commit исходной транзакции. Внутри создавайте отдельный withContext для каждой короткой фазы БД. Не возвращайте из первой фазы lazy entity, repository или объект, который продолжает обращаться к закрытому соединению.

Если пользователь должен сразу увидеть индикатор, добавьте ShowLoadingEvent в текущий response и используйте один UUID для последующего HideLoadingEvent; обязательно закрывайте индикатор в finally. Для обычного однофазного сценария предпочтительнее готовый submitWithLoading.

Ошибка и отдельная транзакция состояния

При исключении основной контекст worker не подтверждается. Поэтому запись статуса error в том же контексте вместе с повторным throw также не будет закоммичена.

Если состояние ошибки обязательно, используйте отдельную транзакцию:

try {
    val result = externalApi.execute(request)
    saveSuccessfulResult(result)
} catch (e: Exception) {
    ServerContext.get().rollback()

    withContext(recipient, transactional = true) {
        getService<ProcessService>().updateStatus(processId, errorStatusId)
        invokeAfterCommit {
            ClientEvents.openProcess(recipientId, processId)
        }
    }

    throw BGMessageException("Внешний сервис не выполнил операцию", e)
}

Повторный throw нужен, чтобы submitWithLoading отправил ошибку, а телеметрия AsyncWorker получила статус failure. Для пользовательских и технических ошибок используйте принятые в проекте BGMessageException/BGException.

Нет общей транзакции с внешней системой

Commit MySQL и ответ внешнего API не образуют атомарную операцию. Возможны состояния:

  • внешний сервис выполнил команду, но ERP не сохранила ответ;

  • ERP сохранила pending, но задача потерялась при остановке JVM;

  • timeout произошёл после того, как внешний сервис уже принял запрос;

  • пользователь повторил операцию и создал дубль.

Для операций записи предусмотрите:

  • стабильный idempotency key, сохраняемый до запуска worker и передаваемый внешнему API;

  • состояния pending, processing, done, error и время последней попытки;

  • уникальное ограничение или проверку дубля на стороне ERP;

  • повторное чтение результата либо reconciliation для неопределённого исхода;

  • возможность безопасно повторить зависшую операцию.

Если внешний API не поддерживает идемпотентность, автоматический retry команды записи опасен. Повторяйте только заведомо безопасную операцию либо сначала проверяйте результат предыдущей попытки.

HTTP-клиент и защита внешнего сервиса

Для нового HTTP-кода используйте штатный Retrofit-клиент. Его методы синхронны, что нормально для виртуального потока AsyncWorker.

Для каждой интеграции задайте:

  • конечные connectTimeout и readTimeout;

  • Rate Limiter, если поток пользовательских задач может превысить лимит сервиса;

  • Circuit Breaker, чтобы не продолжать вал запросов при массовой недоступности;

  • безопасный уровень HTTP-логирования без токенов и персональных данных;

  • статическое @Api.name и статический аргумент type у AsyncWorker для телеметрии.

Параллелизм AsyncWorker не ограничен. Настройки OkHttp Dispatcher ограничивают одновременно выполняемые HTTP-запросы конкретного клиента, но не число фоновых задач, которые могут ожидать разрешение Rate Limiter, соединение или ответ. Для массовой обработки нужен отдельный устойчивый механизм с контролируемой конкуренцией.

Доставка результата пользователю

В текущем HTTP-response событие добавляется через form.response.addEvent(…​). После завершения request используйте ClientEvents: события сохраняются в Redis и выдаются активной вкладке через pool.do.

Событие Назначение

ShowLoadingEvent(uuid, message)

Показать индикатор конкретной операции

HideLoadingEvent(uuid)

Скрыть тот же индикатор

ShowInfoEvent(message)

Показать успешный результат

ShowErrorEvent(message)

Показать безопасное сообщение об ошибке

ProcessOpenEvent(processId)

Открыть или обновить карточку процесса

uuid связывает Show/Hide одной операции. Параллельные операции должны иметь разные UUID. Внеполосная доставка работает только при включённом clientEvents.saveToRedis.enabled; TTL по умолчанию — пять минут.

Наблюдаемость

Для диагностики должны одновременно существовать:

  • бизнес-запись со статусом операции и correlation/idempotency key;

  • лог начала и результата без секретов;

  • async_worker_* по статическому типу задачи;

  • http_retrofit_request_* по API и endpoint;

  • счётчики Rate Limiter/Circuit Breaker и соответствующие MBean;

  • пользовательский итог в БД, не только исчезающее UI-событие.

Не добавляйте process ID, UUID и номера договоров в теги метрик. Для точечного поиска помещайте их в структурированные поля лога/MDC.

Чек-лист

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

  • задача стартует только после commit исходной операции;

  • JDBC-соединение не удерживается во время внешнего вызова;

  • состояние восстанавливается после закрытия вкладки и рестарта ERP;

  • есть timeout, ограничение нагрузки и понятное поведение при недоступности;

  • команда идемпотентна либо защищена от дублей;

  • success/error сохраняются до отправки UI-события;

  • loader скрывается при успехе и ошибке;

  • пользователю не показываются секреты и технические детали;

  • аргумент type у AsyncWorker и метрики не содержат динамических значений;

  • потерянную или зависшую операцию можно найти и безопасно повторить.