Асинхронная работа с внешними сервисами
Долгий вызов сторонней системы не должен удерживать HTTP-запрос пользователя и транзакцию БД. Стандартный сценарий ERP разделяет принятие операции, внешний вызов и фиксацию результата.
HTTP-запрос
-> проверить входные данные и права
-> сохранить намерение/статус операции
-> показать пользователю индикатор
-> commit
|
v
AsyncWorker
-> вызвать внешний сервис с timeout и ограничителями
-> короткой транзакцией сохранить результат или ошибку
-> после commit отправить ClientEvents
|
v
активная вкладка получает события через pool.do и обновляет интерфейс
Главное правило: внешний побочный эффект запускается только после commit данных, которые его породили. JDBC-соединение при этом не должно оставаться открытым на время сетевого ожидания.
Когда выполнять асинхронно
Выносите операцию в фон, если выполняется хотя бы одно условие:
-
внешний сервис может отвечать дольше комфортного времени HTTP-запроса;
-
ответ не нужен для формирования текущей страницы;
-
операция состоит из нескольких сетевых запросов;
-
пользователь может продолжать работу до получения результата;
-
требуется отдельный статус выполнения и последующее обновление карточки.
Оставляйте вызов синхронным, если без его результата нельзя принять или отклонить текущую команду, а время ответа ограничено коротким timeout. Асинхронность не исправляет отсутствующие timeout, идемпотентность или обработку ошибок — она только отделяет время выполнения от HTTP-запроса.
Стандартный пользовательский сценарий
-
В транзакции запроса проверьте права и входные данные.
-
Сохраните состояние
pending/«в обработке» и идентификатор операции. Это источник истины для повторного открытия страницы. -
Захватите в локальные
val/finalтолько идентификаторы, неизменяемый request DTO и реального пользователя. -
Вызовите
AsyncWorker.submitWithLoading(…). Он покажет индикатор сразу, но передаст задачу executor только после commit. -
В worker вызовите внешний API. Не открывайте перед этим соединение с БД.
-
Сохраните успешный результат короткой транзакцией и только после её commit отправьте
ShowInfoEvent/ProcessOpenEvent. -
При ошибке сохраните состояние
error, отправьте безопасное сообщение и обновите карточку. -
Индикатор должен быть скрыт при любом исходе;
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.
| Событие | Назначение |
|---|---|
|
Показать индикатор конкретной операции |
|
Скрыть тот же индикатор |
|
Показать успешный результат |
|
Показать безопасное сообщение об ошибке |
|
Открыть или обновить карточку процесса |
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и метрики не содержат динамических значений; -
потерянную или зависшую операцию можно найти и безопасно повторить.