Status: Accepted
Date: 2026-04-18
ADR-0010 зафиксировал task-based как primary pattern для генерации протокола (10+ минут — нельзя sync). Здесь — детали реализации: как именно task хранится, как запускается, как узнаётся статус, как чистится.
Реф-реализация (ProtocolTaskController + TaskStorageService + ProtocolGenerationTask):
taskId: String — не UUID.progress: Integer — поле, но реально принимает только 3 значения (0/10/100).modifiedQuestionsJson: String — legacy от Aspose, всегда пустой после миграции на office-stamper.createdBy / ownership check — любой авторизованный видит любой task.SecurityContext bridge в AsyncConfiguration, ручной MDC snapshot в контроллере.ProtocolGenerationTaskEntity в modules/protocol/persistence/ с минимальным набором полей:
UUID taskId (PK)
UUID agendaId — повестка
TaskStatus status — PENDING | IN_PROGRESS | COMPLETED | FAILED
UUID protocolId — nullable, заполняется при COMPLETED
String errorMessage — nullable TEXT, заполняется при FAILED
String createdBy — login создателя, snapshot на submit
Instant createdAt, updatedAt, completedAt
Убрано: progress (тривиальный), modifiedQuestionsJson (dead Aspose-feature). taskId теперь UUID (type-safe, компактнее в БД).
@Transactional
public UUID submit(UUID agendaId) {
UUID taskId = UUID.randomUUID();
String createdBy = SecurityContext.currentUserLogin();
var task = new ProtocolGenerationTaskEntity(taskId, agendaId, PENDING, createdBy, now);
repository.save(task);
asyncExecutor.execute(() -> {
// ContextSnapshot уже применён через ContextPropagatingTaskDecorator — см. AsyncConfig
MDC.put("taskId", taskId.toString());
try {
updateStatus(taskId, IN_PROGRESS);
UUID protocolId = generator.generate(agendaId); // 4 фазы pipeline
completeTask(taskId, protocolId);
} catch (Exception e) {
failTask(taskId, e.getMessage());
// НЕ throw — runAsync fire-and-forget, клиент узнает через polling
}
});
return taskId;
}
CompletableFuture.runAsync(...) заменяется на просто executor.execute(...) — нам не нужно .join() / cancel через future. Fire-and-forget.
@Bean("protocolTaskExecutor")
public Executor protocolTaskExecutor() {
var vt = Executors.newVirtualThreadPerTaskExecutor();
return new ContextPropagatingTaskDecorator(vt);
// Auto-snapshot ContextSnapshot per-submission, restore внутри task
// SecurityContext + MDC + Observation + любые ThreadLocalAccessor's
}
Никакого ручного SecurityContextHolder.setContext() / MDC.setContextMap() в call-sites. Всё через стандартный Spring 6.1+ декоратор.
Альтернатива ContextSnapshot.captureAll() в call-site — делаем если декоратор окажется неподходящим.
@GetMapping("/task/{taskId}/status")
public ResponseEntity<TaskStatusResponse> getStatus(@PathVariable UUID taskId) {
var task = taskService.getTask(taskId); // throws NotFound если нет
logOwnershipMismatch(task); // soft check — только warn, без 403
return ResponseEntity.ok(TaskStatusResponse.of(task));
}
Ownership check — soft. Поле createdBy заполняется, но запрос не блокируется для других пользователей. Причины:
taskId — random UUID, не guessable → низкий практический риск.questions-to-business.md).log.warn("Task {} accessed by {} (owner: {})")). Когда решение придёт — ужесточение на уровне ProtocolGenerationTaskService.getTask одной строкой.@Component
public class ProtocolTaskCleanupScheduler {
@Scheduled(cron = "0 0 3 * * *") // 03:00 ежедневно
@Transactional
public void cleanupOldTasks() {
Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
int deleted = repository.deleteByStatusInAndCompletedAtBefore(
Set.of(COMPLETED, FAILED), cutoff);
log.info("Cleaned up {} old protocol tasks", deleted);
}
}
Живёт в modules/app/scheduler/ (Phase 9 app config). Default retention 7 дней — будет уточнено в questions-to-business.md.
POST /api/v2/protocol/merge/byAgenda/{agendaId}/task
→ 202 Accepted, body: { taskId: UUID, status: PENDING }
GET /api/v2/protocol/task/{taskId}/status
→ 200 OK, body: { taskId, status, protocolId?, errorMessage?, createdAt, updatedAt, completedAt? }
→ 404 если task не найден (через DomainException.NotFound)
EmptyDto в теле POST'а убираем — body не нужен.
Positive:
UUID taskId — type-safe везде, меньше conversion-точек.createdBy) зафиксирован — можно включить enforcement одной строкой.ContextPropagatingTaskDecorator — единая механика.Negative:
phase: TaskPhase enum и колбэк из Generator в Service.newVirtualThreadPerTaskExecutor справится.ContextPropagatingTaskDecorator)