diff --git a/src/main/java/com/example/dvely/agent/infrastructure/config/AsyncConfig.java b/src/main/java/com/example/dvely/agent/infrastructure/config/AsyncConfig.java
index 8fc9a922..2aff220a 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/config/AsyncConfig.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/config/AsyncConfig.java
@@ -148,6 +148,51 @@ public Executor previewExecutor() {
return executor;
}
+ // #340 5-3: 웹훅 배달 처리를 스케줄러 스레드에서 떼어낸다. 핸들러가 GitHub API 를 호출하므로
+ // 한 배달이 느리면 그 동안 이 워커의 다음 폴링이 통째로 밀리고, 스케줄러 풀을 공유하는 다른
+ // 잡까지 굶는다. 한 폴링이 최대 CLAIM_BATCH_SIZE(10) 건을 넘기므로 큐를 그보다 넉넉히 둔다.
+ @Bean("webhookExecutor")
+ public ThreadPoolTaskExecutor webhookExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(4);
+ executor.setQueueCapacity(50);
+ executor.setThreadNamePrefix("webhook-");
+ executor.setAllowCoreThreadTimeOut(true);
+ executor.initialize();
+ return executor;
+ }
+
+ // #340 5-5: 도메인 검증 프로브(Cloudflare + GitHub + HTTPS)를 스케줄러 스레드에서 떼어낸다.
+ // 배치 20건을 직렬로 도는 동안 한 건이 타임아웃까지 버티면 그 시간이 그대로 스케줄러 점유가
+ // 된다. 동시 실행을 낮게 두는 이유는 이 호출들이 외부 API 레이트 리밋을 쓰기 때문이다.
+ @Bean("domainVerificationExecutor")
+ public ThreadPoolTaskExecutor domainVerificationExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(4);
+ executor.setQueueCapacity(50);
+ executor.setThreadNamePrefix("domain-verify-");
+ executor.setAllowCoreThreadTimeOut(true);
+ executor.initialize();
+ return executor;
+ }
+
+ // #340 5-10: 도커 prune 처럼 오래 걸리는 정비 작업 전용. prune 3회가 도커 데몬을 잠시 붙잡는
+ // 동안 스케줄러 스레드를 점유하지 않게 한다. 6시간에 한 번 도는 일이라 놀 때는 스레드를
+ // 회수한다(allowCoreThreadTimeOut).
+ @Bean("maintenanceExecutor")
+ public ThreadPoolTaskExecutor maintenanceExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(1);
+ executor.setMaxPoolSize(2);
+ executor.setQueueCapacity(10);
+ executor.setThreadNamePrefix("maintenance-");
+ executor.setAllowCoreThreadTimeOut(true);
+ executor.initialize();
+ return executor;
+ }
+
@Bean("cloudConnectionExecutor")
public Executor cloudConnectionExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java b/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java
index b98d4fbe..412678d1 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java
@@ -11,6 +11,8 @@
import com.example.dvely.agent.infrastructure.persistence.entity.AgentRunEventEntity;
import com.example.dvely.agent.infrastructure.persistence.repository.SpringDataAgentRunEventRepository;
import com.example.dvely.agent.infrastructure.persistence.repository.SpringDataAgentRunRepository;
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkQueuedEvent;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
@@ -21,6 +23,7 @@
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
@@ -52,6 +55,7 @@ public class TaskStore {
private final SpringDataAgentRunRepository runRepository;
private final SpringDataAgentRunEventRepository eventRepository;
private final ObjectMapper objectMapper;
+ private final ApplicationEventPublisher eventPublisher;
@Transactional
public void save(AgentTask task) {
@@ -164,6 +168,7 @@ public void enqueue(String taskId) {
AgentRunEntity run = requireRun(taskId);
run.enqueue(false);
appendEvent(taskId, "QUEUED", TaskStatus.QUEUED, "Agent task 실행을 대기합니다.");
+ signalWorkQueued();
}
@Transactional
@@ -177,9 +182,46 @@ public boolean retry(String taskId, Long ownerUserId) {
}
run.enqueue(true);
appendEvent(taskId, "RETRY_QUEUED", TaskStatus.RETRY_WAIT, "수정안을 적용해 작업을 다시 실행합니다.");
+ signalWorkQueued();
return true;
}
+ /**
+ * 폴링 한 번이 하는 일 전부 — 만료 리스 회수와 claim 을 한 트랜잭션으로 묶는다(#340 5-1).
+ *
+ *
둘을 따로 부르면 폴링 한 번이 트랜잭션 두 개가 되고, 트랜잭션 하나는
+ * {@code SET autocommit=0} → 쿼리 → {@code COMMIT} → {@code SET autocommit=1} 로 왕복 네 번이다.
+ * 유휴 상태 실측에서 비용의 대부분이 SELECT 가 아니라 이 의례였다 — 합치는 것만으로 절반이
+ * 줄어든다.
+ *
+ * 덤으로 회수 지연이 한 폴링 짧아진다. 회수 UPDATE 가 같은 트랜잭션에서 먼저 반영되므로,
+ * 방금 RETRY_WAIT 로 돌아온 태스크를 같은 폴링의 claim 이 곧바로 집는다.
+ *
+ * @param claimLimit 0 이하면 claim 쿼리를 아예 내지 않는다 — 실행기가 포화라 집어봐야 곧바로
+ * 되돌려야 하는 상황(ADR-Y3)에서도 회수는 계속 돌아야 하기 때문이다.
+ * 포화를 이유로 폴링을 통째로 건너뛰면 좀비 리스가 그만큼 오래 남는다.
+ */
+ @Transactional
+ public PollBatch recoverAndClaim(String workerId, int claimLimit) {
+ List leaseExhausted = recoverExpiredLeases();
+ List claimed = claimLimit > 0 ? claimRunnableTasks(workerId, claimLimit) : List.of();
+ return new PollBatch(leaseExhausted, claimed);
+ }
+
+ /**
+ * 폴링 한 번의 결과.
+ *
+ * @param leaseExhausted 복구 횟수를 소진해 FAILED 로 닫힌 taskId — 호출자가 사용자에게 알린다
+ * @param claimed 이번 폴링이 집은 taskId
+ */
+ public record PollBatch(List leaseExhausted, List claimed) {
+
+ /** 이번 폴링이 무언가라도 건드렸는가 — 워커의 백오프 판단 근거. */
+ public boolean touchedWork() {
+ return !leaseExhausted.isEmpty() || !claimed.isEmpty();
+ }
+ }
+
@Transactional
public List claimRunnableTasks(String workerId, int limit) {
List candidates = runRepository.findRunnableTaskIds(
@@ -398,6 +440,7 @@ public void recoverStuckApproval(String taskId) {
TaskStatus.QUEUED,
"지연된 승인 처리를 복구해 작업을 시작합니다."
);
+ signalWorkQueued();
}
/**
@@ -554,6 +597,7 @@ private boolean resumePastResultGate(String taskId, String eventType, String mes
return false;
}
appendEvent(taskId, eventType, TaskStatus.QUEUED, message);
+ signalWorkQueued();
return true;
}
@@ -585,6 +629,19 @@ public void replacePlanAndRequeue(String taskId, AgentPlan newPlan) {
}
run.replacePlan(writePlan(newPlan));
appendEvent(taskId, "REPLANNED", TaskStatus.QUEUED, "되묻기 답을 반영해 재계획했습니다.");
+ signalWorkQueued();
+ }
+
+ /**
+ * 이 태스크가 워커가 집을 수 있는 상태가 됐다고 알린다(#340 5-1).
+ *
+ * {@code WorkerPollGate} 가 커밋 뒤에 받아 백오프를 즉시 푼다. 이 신호가 없으면, 유휴가
+ * 길어져 폴링 간격이 상한까지 늘어난 상태에서 사용자가 메시지를 보냈을 때 그 상한만큼
+ * 기다리게 된다 — 명백한 UX 회귀다. 신호가 있으면 백오프 값과 무관하게 다음 틱(≤1초)에
+ * 집힌다.
+ */
+ private void signalWorkQueued() {
+ eventPublisher.publishEvent(new WorkQueuedEvent(WorkQueue.AGENT_RUN));
}
private String writeClarification(ClarificationRequest clarification) {
@@ -607,6 +664,7 @@ public boolean supplyInput(String taskId, Long ownerUserId, String value) {
}
run.supplyInput(value.trim());
appendEvent(taskId, "INPUT_RECEIVED", TaskStatus.QUEUED, "사용자 입력을 받아 task를 다시 대기열에 넣었습니다.");
+ signalWorkQueued();
return true;
}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorker.java b/src/main/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorker.java
index ae8eb570..cc9cb591 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorker.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorker.java
@@ -6,6 +6,8 @@
import com.example.dvely.agent.application.orchestrator.AgentPlanExecutor;
import com.example.dvely.agent.application.service.AgentMessageService;
import com.example.dvely.agent.infrastructure.store.TaskStore;
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkerPollGate;
import java.lang.management.ManagementFactory;
import java.util.List;
import java.util.Set;
@@ -37,6 +39,7 @@ public class AgentRunWorker {
private final AgentMessageService agentMessageService;
private final AgentExecutionRegistry executionRegistry;
private final ThreadPoolTaskExecutor agentExecutor;
+ private final WorkerPollGate pollGate;
private final long dispatchRejectBackoffMs;
private final String workerId = ManagementFactory.getRuntimeMXBean().getName();
@@ -45,6 +48,7 @@ public AgentRunWorker(TaskStore taskStore,
AgentMessageService agentMessageService,
AgentExecutionRegistry executionRegistry,
@Qualifier("agentExecutor") ThreadPoolTaskExecutor agentExecutor,
+ WorkerPollGate pollGate,
@Value("${qeploy.agent.worker.dispatch-reject-backoff-ms:5000}")
long dispatchRejectBackoffMs) {
this.taskStore = taskStore;
@@ -52,12 +56,17 @@ public AgentRunWorker(TaskStore taskStore,
this.agentMessageService = agentMessageService;
this.executionRegistry = executionRegistry;
this.agentExecutor = agentExecutor;
+ this.pollGate = pollGate;
this.dispatchRejectBackoffMs = dispatchRejectBackoffMs;
}
@Scheduled(fixedDelayString = "${qeploy.agent.worker.poll-interval-ms:1000}")
public void dispatchQueuedRuns() {
- notifyLeaseExhausted(taskStore.recoverExpiredLeases());
+ // #340 5-1: 틱은 여전히 1초마다 오지만, 일이 없는 동안에는 DB 를 치지 않는다. 게이트가
+ // 닫혀 있으면 여기서 끝이고 쿼리는 한 건도 나가지 않는다.
+ if (!pollGate.shouldPoll(WorkQueue.AGENT_RUN)) {
+ return;
+ }
// ADR-Y3 SHOULD: best-effort capacity check before claiming at all. Deliberately racy (the
// pool's real state can change the instant after this read) — it only needs to be
@@ -66,13 +75,32 @@ public void dispatchQueuedRuns() {
// window where a claimed task shows as RUNNING while merely queued inside the executor
// (audit §4.1's "상태 의미 왜곡" note).
int freeSlots = estimateFreeExecutorSlots();
- if (freeSlots <= 0) {
+ boolean saturated = freeSlots <= 0;
+ if (saturated) {
log.debug("[AgentRunWorker] agentExecutor 포화로 이번 폴링은 claim을 생략합니다. workerId={}", workerId);
- return;
}
- List taskIds = taskStore.claimRunnableTasks(workerId, Math.min(CLAIM_BATCH_SIZE, freeSlots));
- for (String taskId : taskIds) {
+ TaskStore.PollBatch batch;
+ try {
+ // 포화여도 회수는 돌린다(claimLimit=0). 포화를 이유로 폴링을 통째로 건너뛰면 좀비
+ // 리스가 그만큼 오래 남는다.
+ batch = taskStore.recoverAndClaim(workerId, saturated ? 0 : Math.min(CLAIM_BATCH_SIZE, freeSlots));
+ } catch (RuntimeException exception) {
+ // DB 가 흔들리는 동안 매초 같은 쿼리를 다시 던져봐야 소용이 없다 — 물러나며 재시도한다.
+ pollGate.recordIdle(WorkQueue.AGENT_RUN);
+ throw exception;
+ }
+
+ // 포화로 claim 을 생략한 것은 "일이 없다"가 아니다. 자리가 나는 것을 알려줄 신호는 없으므로
+ // 여기서 물러나면 큐에 쌓인 태스크가 백오프 상한만큼 늦게 출발한다.
+ if (saturated || batch.touchedWork()) {
+ pollGate.recordBusy(WorkQueue.AGENT_RUN);
+ } else {
+ pollGate.recordIdle(WorkQueue.AGENT_RUN);
+ }
+
+ notifyLeaseExhausted(batch.leaseExhausted());
+ for (String taskId : batch.claimed()) {
dispatchOne(taskId);
}
}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/worker/DockerGarbageSweeper.java b/src/main/java/com/example/dvely/agent/infrastructure/worker/DockerGarbageSweeper.java
index 7c37200f..fb4f9a3d 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/worker/DockerGarbageSweeper.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/worker/DockerGarbageSweeper.java
@@ -2,8 +2,9 @@
import com.example.dvely.agent.infrastructure.docker.DockerContainerService;
import java.time.Duration;
-import lombok.RequiredArgsConstructor;
+import java.util.concurrent.Executor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -30,10 +31,16 @@
*/
@Slf4j
@Component
-@RequiredArgsConstructor
public class DockerGarbageSweeper {
private final DockerContainerService dockerService;
+ private final Executor maintenanceExecutor;
+
+ public DockerGarbageSweeper(DockerContainerService dockerService,
+ @Qualifier("maintenanceExecutor") Executor maintenanceExecutor) {
+ this.dockerService = dockerService;
+ this.maintenanceExecutor = maintenanceExecutor;
+ }
/** 이 시간 안에 쓰인 빌드 캐시는 남긴다. */
@Value("${qeploy.docker.sweep.build-cache-keep-hours:48}")
@@ -53,6 +60,12 @@ public void sweep() {
if (!enabled) {
return;
}
+ // #340 5-10: prune 3회는 도커 데몬을 잠시 붙잡는다. 그 시간을 스케줄러 스레드로 때우면
+ // 같은 풀을 쓰는 다른 잡이 그만큼 밀린다 — 6시간에 한 번이라도 전용 스레드로 넘긴다.
+ maintenanceExecutor.execute(this::pruneGarbage);
+ }
+
+ private void pruneGarbage() {
long freed = dockerService.pruneGarbage(Duration.ofHours(buildCacheKeepHours));
if (freed < 0) {
return; // 도커에 못 닿음 — pruneGarbage 가 이미 경고를 남겼다
diff --git a/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java b/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java
index 82da56ce..5f1bf380 100644
--- a/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java
+++ b/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java
@@ -96,16 +96,15 @@ public void deleteConversationsForProject(Long userId, Long projectId) {
}
}
+ /**
+ * 만료된 휴지통 대화를 영구 삭제한다.
+ *
+ * #340 5-9: 엔티티를 전부 로드한 뒤 {@code deleteById} 를 N 번 부르던 것을 벌크 DELETE
+ * 한 문장으로 바꿨다. 지우려고 읽을 이유가 없다 — 삭제 조건이 곧 SELECT 조건이었다.
+ */
@Transactional
public int purgeExpiredConversations() {
- List expired = conversationRepository.findAllByDeletedTrueAndDeletedAtLessThanEqual(
- ChatTrashPolicy.cutoff(LocalDateTime.now())
- );
- expired.stream()
- .map(Conversation::getId)
- .filter(java.util.Objects::nonNull)
- .forEach(conversationRepository::deleteById);
- return expired.size();
+ return conversationRepository.deleteExpiredTrash(ChatTrashPolicy.cutoff(LocalDateTime.now()));
}
@Transactional
diff --git a/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java b/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java
index 8ee5b0e4..c0a10e12 100644
--- a/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java
+++ b/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java
@@ -21,5 +21,12 @@ public interface ConversationRepository {
void deleteById(Long conversationId);
+ /**
+ * 만료된 휴지통 대화를 한 문장으로 지운다(#340 5-9).
+ *
+ * @return 지워진 대화 수
+ */
+ int deleteExpiredTrash(LocalDateTime cutoff);
+
Conversation save(Conversation conversation);
}
diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java
index 18abc4ab..252fd9f3 100644
--- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java
@@ -60,6 +60,11 @@ public void deleteById(Long conversationId) {
springDataConversationRepository.deleteById(conversationId);
}
+ @Override
+ public int deleteExpiredTrash(LocalDateTime cutoff) {
+ return springDataConversationRepository.deleteExpiredTrash(cutoff);
+ }
+
@Override
public Conversation save(Conversation conversation) {
ConversationEntity entity;
diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java
index 1f3ee549..d5e948a2 100644
--- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java
+++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java
@@ -5,6 +5,9 @@
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface SpringDataConversationRepository extends JpaRepository {
@@ -19,4 +22,16 @@ public interface SpringDataConversationRepository extends JpaRepository findByIdAndUserIdAndDeletedFalse(Long conversationId, Long userId);
Optional findByIdAndUserId(Long conversationId, Long userId);
+
+ // #340 5-9: 만료된 휴지통 대화를 한 문장으로 지운다. 예전에는 엔티티를 전부 로드한 뒤
+ // deleteById 를 N 번 불렀다 — 지우려고 읽고, 지우려고 또 한 번씩 왕복했다. 파생
+ // deleteBy... 메서드도 내부적으로 같은 짓을 하므로 명시적 벌크 DELETE 로 적는다.
+ // 여기서 지우는 대상은 이미 소프트 삭제돼 사용자 화면에서 사라진 대화뿐이다.
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query("""
+ delete from ConversationEntity conversation
+ where conversation.deleted = true
+ and conversation.deletedAt <= :cutoff
+ """)
+ int deleteExpiredTrash(@Param("cutoff") LocalDateTime cutoff);
}
diff --git a/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionVerificationJobRepository.java b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionVerificationJobRepository.java
index f949127e..803a9544 100644
--- a/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionVerificationJobRepository.java
+++ b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionVerificationJobRepository.java
@@ -16,5 +16,21 @@ public interface CloudConnectionVerificationJobRepository {
List claimPending(String workerId, int limit);
+ /**
+ * 폴링 한 번이 하는 일 전부 — 만료 리스 회수와 claim 을 한 트랜잭션으로 묶는다(#340 5-1).
+ * 따로 부르면 폴링 한 번이 트랜잭션 두 개가 되고, 트랜잭션마다 붙는 {@code SET autocommit} ·
+ * {@code COMMIT} 의례가 유휴 DB 비용의 대부분이었다. 회수 UPDATE 가 같은 트랜잭션에서 먼저
+ * 반영되므로, 방금 회수된 행을 같은 폴링의 claim 이 곧바로 집는다.
+ */
+ List recoverAndClaimPending(String workerId, int limit);
+
void recoverExpiredLeases();
+
+ /**
+ * claim 해 놓고 실행기에 넘기지 못한 job 을 PENDING 으로 되돌린다(#340 5-2).
+ *
+ * @return 이 호출이 실제로 되돌렸으면 true. false 는 그 사이 다른 주체가 이미 이 행을
+ * RUNNING 밖으로 옮겼다는 뜻이다.
+ */
+ boolean releaseClaim(String jobId, String workerId);
}
diff --git a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionVerificationJobRepositoryAdapter.java b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionVerificationJobRepositoryAdapter.java
index 9ab13aa5..328039f7 100644
--- a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionVerificationJobRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionVerificationJobRepositoryAdapter.java
@@ -5,10 +5,13 @@
import com.example.dvely.cloudconnection.domain.value.CloudConnectionStatus;
import com.example.dvely.cloudconnection.domain.value.CloudConnectionVerificationJobStatus;
import com.example.dvely.cloudconnection.infrastructure.persistence.entity.CloudConnectionVerificationJobEntity;
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkQueuedEvent;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@@ -19,13 +22,19 @@ public class CloudConnectionVerificationJobRepositoryAdapter
implements CloudConnectionVerificationJobRepository {
private final SpringDataCloudConnectionVerificationJobRepository springDataRepository;
+ private final ApplicationEventPublisher eventPublisher;
@Override
public CloudConnectionVerificationJob save(CloudConnectionVerificationJob job) {
CloudConnectionVerificationJobEntity entity = springDataRepository.findById(job.getId())
.orElseGet(() -> CloudConnectionVerificationJobEntity.from(job));
entity.updateFrom(job);
- return springDataRepository.save(entity).toDomain();
+ CloudConnectionVerificationJob saved = springDataRepository.save(entity).toDomain();
+ if (saved.getStatus() == CloudConnectionVerificationJobStatus.PENDING) {
+ // #340 5-1: 워커가 집을 수 있는 상태로 저장됐다 — 커밋 뒤에 워커를 깨운다.
+ eventPublisher.publishEvent(new WorkQueuedEvent(WorkQueue.CLOUD_CONNECTION_VERIFICATION));
+ }
+ return saved;
}
@Override
@@ -68,6 +77,26 @@ public List claimPending(String workerId, int limit) {
.toList();
}
+ @Override
+ @Transactional
+ public List recoverAndClaimPending(String workerId, int limit) {
+ recoverExpiredLeases();
+ return claimPending(workerId, limit);
+ }
+
+ @Override
+ @Transactional
+ public boolean releaseClaim(String jobId, String workerId) {
+ return springDataRepository.releaseClaim(
+ jobId,
+ workerId,
+ LocalDateTime.now(),
+ CloudConnectionVerificationJobStatus.RUNNING.name(),
+ CloudConnectionVerificationJobStatus.PENDING.name(),
+ "실행기가 포화 상태라 클라우드 권한 확인을 재대기열로 돌렸습니다."
+ ) == 1;
+ }
+
@Override
@Transactional
public void recoverExpiredLeases() {
diff --git a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionVerificationJobRepository.java b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionVerificationJobRepository.java
index c6fb5942..85118693 100644
--- a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionVerificationJobRepository.java
+++ b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionVerificationJobRepository.java
@@ -52,6 +52,34 @@ int claim(
@Param("message") String message
);
+ // #340 5-2: claim 된 job 을 실행기에 넘기지 못했을 때 PENDING 으로 되돌린다. claim 이 올린
+ // attempt 를 그대로 되돌린다 — 실행기 포화는 이 job 의 실패가 아니라 배압이다.
+ // connection_status 를 VERIFYING 으로 두는 것은 retryAfterExpiredLease 와 같다: 사용자가 검증을
+ // 요청해 둔 상태 그대로이고, 곧 다음 폴링이 다시 집는다. WHERE 는 claim 과 같은 조건부 UPDATE
+ // 모양이라 이 claim 을 실제로 쥔 워커만 되돌린다.
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query("""
+ update CloudConnectionVerificationJobEntity job
+ set job.status = :pendingStatus,
+ job.attempt = job.attempt - 1,
+ job.message = :message,
+ job.startedAt = null,
+ job.leaseOwner = null,
+ job.leaseUntil = null,
+ job.updatedAt = :now
+ where job.id = :jobId
+ and job.status = :runningStatus
+ and job.leaseOwner = :workerId
+ """)
+ int releaseClaim(
+ @Param("jobId") String jobId,
+ @Param("workerId") String workerId,
+ @Param("now") LocalDateTime now,
+ @Param("runningStatus") String runningStatus,
+ @Param("pendingStatus") String pendingStatus,
+ @Param("message") String message
+ );
+
List findByStatusAndLeaseUntilBefore(
String status,
LocalDateTime leaseUntil
diff --git a/src/main/java/com/example/dvely/cloudconnection/infrastructure/worker/CloudConnectionVerificationWorker.java b/src/main/java/com/example/dvely/cloudconnection/infrastructure/worker/CloudConnectionVerificationWorker.java
index 0670b639..e53b4b6d 100644
--- a/src/main/java/com/example/dvely/cloudconnection/infrastructure/worker/CloudConnectionVerificationWorker.java
+++ b/src/main/java/com/example/dvely/cloudconnection/infrastructure/worker/CloudConnectionVerificationWorker.java
@@ -2,12 +2,27 @@
import com.example.dvely.cloudconnection.application.service.CloudConnectionVerificationService;
import com.example.dvely.cloudconnection.domain.repository.CloudConnectionVerificationJobRepository;
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkerPollGate;
import java.lang.management.ManagementFactory;
+import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
+/**
+ * PENDING 검증 job 을 집어 {@link CloudConnectionVerificationService#executeQueued}(비동기) 로 넘긴다.
+ *
+ * #340 5-2: {@code DeploymentRunWorker} 와 같은 이유로 위임을 job 하나씩 격리한다 —
+ * {@code cloudConnectionExecutor} 포화로 던져진 {@code TaskRejectedException} 하나가 루프를 끊으면,
+ * 같은 배치에서 이미 claim 된 다음 job 이 RUNNING 인 채 리스 만료(2분)까지 방치된다. 그 동안
+ * 사용자에게는 "권한을 확인하고 있습니다"만 떠 있고 실제로 확인하는 것은 아무것도 없다.
+ *
+ * 배포와 달리 이 job 에는 {@code next_run_at} 이 없어 되돌린 job 은 다음 폴링에 곧바로 다시
+ * 집힌다. 포화가 이어지는 동안 claim↔release 가 초당 반복되지만, 그것은 유계이고(배치 2건)
+ * 자리가 나는 즉시 스스로 풀린다 — 행이 실행 없이 RUNNING 에 갇히는 쪽이 훨씬 나쁘다.
+ */
@Slf4j
@Component
@RequiredArgsConstructor
@@ -17,14 +32,40 @@ public class CloudConnectionVerificationWorker {
private final CloudConnectionVerificationJobRepository verificationJobRepository;
private final CloudConnectionVerificationService verificationService;
+ private final WorkerPollGate pollGate;
private final String workerId = ManagementFactory.getRuntimeMXBean().getName() + "-cloud-connection";
@Scheduled(fixedDelayString = "${qeploy.cloud-connection.worker.poll-interval-ms:1000}")
public void dispatchPendingJobs() {
- verificationJobRepository.recoverExpiredLeases();
- for (String jobId : verificationJobRepository.claimPending(workerId, CLAIM_BATCH_SIZE)) {
+ // #340 5-1: 틱은 1초마다 오지만, 일이 없는 동안에는 DB 를 치지 않는다.
+ if (!pollGate.shouldPoll(WorkQueue.CLOUD_CONNECTION_VERIFICATION)) {
+ return;
+ }
+ List jobIds;
+ try {
+ jobIds = verificationJobRepository.recoverAndClaimPending(workerId, CLAIM_BATCH_SIZE);
+ } catch (RuntimeException exception) {
+ pollGate.recordIdle(WorkQueue.CLOUD_CONNECTION_VERIFICATION);
+ throw exception;
+ }
+ if (jobIds.isEmpty()) {
+ pollGate.recordIdle(WorkQueue.CLOUD_CONNECTION_VERIFICATION);
+ } else {
+ pollGate.recordBusy(WorkQueue.CLOUD_CONNECTION_VERIFICATION);
+ }
+ for (String jobId : jobIds) {
+ dispatchOne(jobId);
+ }
+ }
+
+ private void dispatchOne(String jobId) {
+ try {
log.info("클라우드 연결 검증 위임: jobId={} workerId={}", jobId, workerId);
verificationService.executeQueued(jobId);
+ } catch (RuntimeException exception) {
+ boolean released = verificationJobRepository.releaseClaim(jobId, workerId);
+ log.warn("클라우드 연결 검증 위임 실패 — 재대기열로 반환합니다. jobId={} workerId={} released={} 원인={}",
+ jobId, workerId, released, exception.toString());
}
}
}
diff --git a/src/main/java/com/example/dvely/common/worker/NextCheckSchedule.java b/src/main/java/com/example/dvely/common/worker/NextCheckSchedule.java
new file mode 100644
index 00000000..2a675ea3
--- /dev/null
+++ b/src/main/java/com/example/dvely/common/worker/NextCheckSchedule.java
@@ -0,0 +1,77 @@
+package com.example.dvely.common.worker;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
+
+/**
+ * "이 대상은 다음에 언제 다시 볼 것인가"를 들고 있는 인메모리 장부(#340 5-4 · 5-5).
+ *
+ * 왜 필요한가. 폴러가 고정 주기로 돌면서 후보 전부를 매 주기 건드리면, 결과가 느리게
+ * 바뀌는 대상(GitHub Actions 실행 · DNS 전파)에 대해 같은 외부 API 호출을 수백 번 반복한다.
+ * 멈춘 배포 한 건이 포기 시각(120분)까지 매분 GitHub 을 치면 120회, 커스텀 도메인 한 건이
+ * TTL(1440분)까지 매분 Cloudflare·GitHub·HTTPS 프로브를 돌면 1,440회다. 폴러의 주기는 그대로
+ * 두고, 대상별로 다음에 볼 시각을 미루는 것이 이 장부의 일이다.
+ *
+ * DB 가 아니라 메모리인 이유. 이 간격은 정확성이 아니라 예의의 문제다 — 재시작하면
+ * 장부가 비고 모든 대상을 한 번씩 다시 보게 되는데, 그것이 정확히 옳은 동작이다(재시작 직후에는
+ * 상태가 바뀌었을 가능성이 오히려 높다). 인스턴스가 여럿이면 각자 자기 장부를 갖고, 호출 횟수는
+ * 인스턴스 수만큼 곱해진다 — 그래도 대상당 수백 회가 수 회로 줄어드는 효과는 그대로다.
+ *
+ * 누수. 대상은 언젠가 사라진다(배포가 닫히고 도메인이 연결된다). 결론이 난 대상은
+ * 호출자가 {@link #clear} 로 지우지만, 호출자가 다시 보지 못하게 된 대상(행 삭제 등)은 지울 기회
+ * 자체가 없다. 그래서 오래 손대지 않은 항목은 스스로 만료시킨다.
+ */
+public class NextCheckSchedule {
+
+ private static final long ENTRY_TTL_MS = TimeUnit.HOURS.toMillis(1);
+
+ private final Map entries = new ConcurrentHashMap<>();
+ private final LongSupplier nanoTime;
+
+ public NextCheckSchedule() {
+ this(System::nanoTime);
+ }
+
+ /** 시계를 주입하는 생성자 — 간격은 시간에 의존하므로 테스트가 시계를 쥘 수 있어야 한다. */
+ public NextCheckSchedule(LongSupplier nanoTime) {
+ this.nanoTime = nanoTime;
+ }
+
+ /** 지금 이 대상을 볼 차례인가. 장부에 없으면(처음 보는 대상) 언제나 그렇다. */
+ public boolean due(K key) {
+ Entry entry = entries.get(key);
+ return entry == null || nanoTime.getAsLong() - entry.nextCheckAtNanos >= 0;
+ }
+
+ /** 이 대상을 몇 번이나 결론 없이 봤는가 — 호출자가 간격 단계를 고르는 데 쓴다. */
+ public int inconclusiveChecks(K key) {
+ Entry entry = entries.get(key);
+ return entry == null ? 0 : entry.inconclusiveChecks;
+ }
+
+ /** 이 대상을 {@code delayMillis} 뒤에 다시 본다. */
+ public void scheduleAfter(K key, long delayMillis) {
+ long now = nanoTime.getAsLong();
+ entries.compute(key, (ignored, previous) -> new Entry(
+ now + TimeUnit.MILLISECONDS.toNanos(delayMillis),
+ previous == null ? 1 : previous.inconclusiveChecks + 1,
+ now
+ ));
+ evictStale(now);
+ }
+
+ /** 결론이 났다 — 장부에서 지운다. 같은 대상이 다시 나타나면 처음부터 센다. */
+ public void clear(K key) {
+ entries.remove(key);
+ }
+
+ private void evictStale(long now) {
+ long ttlNanos = TimeUnit.MILLISECONDS.toNanos(ENTRY_TTL_MS);
+ entries.values().removeIf(entry -> now - entry.touchedAtNanos > ttlNanos);
+ }
+
+ private record Entry(long nextCheckAtNanos, int inconclusiveChecks, long touchedAtNanos) {
+ }
+}
diff --git a/src/main/java/com/example/dvely/common/worker/WorkQueue.java b/src/main/java/com/example/dvely/common/worker/WorkQueue.java
new file mode 100644
index 00000000..4eaec212
--- /dev/null
+++ b/src/main/java/com/example/dvely/common/worker/WorkQueue.java
@@ -0,0 +1,20 @@
+package com.example.dvely.common.worker;
+
+/**
+ * 1초 폴링 워커가 감시하는 작업 큐. {@link WorkerPollGate} 의 백오프 상태와 깨우기 신호가
+ * 이 값 단위로 갈린다 — 배포 큐에 일이 들어왔다고 웹훅 워커까지 깨울 이유는 없다.
+ */
+public enum WorkQueue {
+
+ /** {@code agent_runs} — 사용자 메시지로 만들어진 Agent 태스크. */
+ AGENT_RUN,
+
+ /** {@code deployment_histories} — 사용자가 누른 배포. */
+ DEPLOYMENT_RUN,
+
+ /** {@code cloud_connection_verification_jobs} — 사용자가 요청한 클라우드 권한 검증. */
+ CLOUD_CONNECTION_VERIFICATION,
+
+ /** {@code webhook_deliveries} — GitHub 이 보내온 웹훅. */
+ WEBHOOK_DELIVERY
+}
diff --git a/src/main/java/com/example/dvely/common/worker/WorkQueuedEvent.java b/src/main/java/com/example/dvely/common/worker/WorkQueuedEvent.java
new file mode 100644
index 00000000..d30a64c9
--- /dev/null
+++ b/src/main/java/com/example/dvely/common/worker/WorkQueuedEvent.java
@@ -0,0 +1,12 @@
+package com.example.dvely.common.worker;
+
+/**
+ * 어떤 큐에 워커가 집을 수 있는 일이 들어갔다는 신호(#340 5-1).
+ *
+ * 커밋된 뒤에 전달돼야 한다 — 커밋 전에 깨우면 워커가 아직 보이지 않는 행을 찾다가
+ * 헛폴링하고 도로 백오프에 들어간다. 그래서 {@link WorkerPollGate} 의 수신부가
+ * {@code @TransactionalEventListener(AFTER_COMMIT)} 이다. 발행부는 그냥 {@code publishEvent}
+ * 하면 되고, 트랜잭션 안이면 커밋 뒤로 미뤄지고 밖이면 즉시 전달된다.
+ */
+public record WorkQueuedEvent(WorkQueue queue) {
+}
diff --git a/src/main/java/com/example/dvely/common/worker/WorkerPollGate.java b/src/main/java/com/example/dvely/common/worker/WorkerPollGate.java
new file mode 100644
index 00000000..ac98ed37
--- /dev/null
+++ b/src/main/java/com/example/dvely/common/worker/WorkerPollGate.java
@@ -0,0 +1,145 @@
+package com.example.dvely.common.worker;
+
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 1초 폴링 워커들의 적응형 백오프 게이트(#340 5-1).
+ *
+ * 왜 필요한가. 유휴(사용자 0명·작업 0건) 상태에서 워커 4종이 매초 DB 를 쳐서 분당
+ * 1904 쿼리가 나갔다. 그 비용의 대부분은 SELECT 자체가 아니라 트랜잭션 의례다 — 폴링 한 번이
+ * {@code SET autocommit=0} → SELECT → {@code COMMIT} → {@code SET autocommit=1} 로 왕복 4배가
+ * 된다. 일이 없을 때 폴링 횟수를 줄이면 의례까지 같은 비율로 줄어든다.
+ *
+ * 왜 {@code @Scheduled} 간격을 직접 늘리지 않는가. 워커의 {@code fixedDelay} 는 1초로
+ * 두고 이 게이트가 DB 를 칠지 말지만 정한다. 스케줄러 스레드가 1초마다 깨어나는 비용은
+ * 인메모리 비교 한 번이고, 대신 두 가지를 공짜로 얻는다 — (1) 깨우기 신호가 오면 다음 틱(≤1초)에
+ * 곧바로 폴링하므로 첫 claim 지연이 백오프와 무관하게 항상 1초 이내다. 동적
+ * {@code Trigger} 로 간격 자체를 늘렸다면 깨우기가 재스케줄까지 해야 했다. (2) 스케줄러
+ * 인프라를 손대지 않으므로 워커별로 켜고 끄기가 자유롭다.
+ *
+ * 깨우기와 폴백 폴링은 둘 다 필요하다. {@link WorkQueuedEvent} 는 이 JVM 안에서만
+ * 도는 인스턴스 로컬 신호다. 다중 인스턴스로 늘리면 다른 인스턴스가 넣은 일은 이 신호를
+ * 타고 오지 않는다. 그래서 백오프에는 반드시 상한이 있고(기본 30초), 상한에 닿아도 폴링은
+ * 멈추지 않는다 — 최악의 경우에도 남의 일을 30초 안에는 본다.
+ *
+ * 깨우기 유실 방지. 폴링이 도는 도중에 신호가 오면, 그 신호가 가리키는 행을 방금 끝난
+ * 폴링이 못 봤을 수 있다. 그때 백오프를 늘리면 이미 들어온 일이 최대 상한만큼 늦어진다. 그래서
+ * 폴링 시작 시점의 세대를 기억해 두고, 끝났을 때 세대가 달라져 있으면 백오프를 늘리지 않는다.
+ */
+@Component
+public class WorkerPollGate {
+
+ private final long baseDelayMs;
+ private final long maxDelayMs;
+ private final LongSupplier nanoTime;
+ private final Map states = new EnumMap<>(WorkQueue.class);
+
+ @Autowired
+ public WorkerPollGate(@Value("${qeploy.worker.poll.base-delay-ms:1000}") long baseDelayMs,
+ @Value("${qeploy.worker.poll.max-delay-ms:30000}") long maxDelayMs) {
+ this(baseDelayMs, maxDelayMs, System::nanoTime);
+ }
+
+ /** 시계를 주입하는 생성자 — 백오프는 시간에 의존하므로 테스트가 시계를 직접 쥘 수 있어야 한다. */
+ public WorkerPollGate(long baseDelayMs, long maxDelayMs, LongSupplier nanoTime) {
+ this.baseDelayMs = Math.max(1L, baseDelayMs);
+ this.maxDelayMs = Math.max(this.baseDelayMs, maxDelayMs);
+ this.nanoTime = nanoTime;
+ long now = nanoTime.getAsLong();
+ for (WorkQueue queue : WorkQueue.values()) {
+ states.put(queue, new QueueState(this.baseDelayMs, now));
+ }
+ }
+
+ /** 이번 틱에 DB 를 쳐도 되는가. {@code false} 면 워커는 아무 쿼리도 내지 않고 돌아간다. */
+ public boolean shouldPoll(WorkQueue queue) {
+ return states.get(queue).beginPoll(nanoTime.getAsLong());
+ }
+
+ /**
+ * 이번 폴링이 일을 찾았다(또는 실행기 포화로 claim 을 미뤘다). 간격을 최소로 되돌린다 —
+ * 일이 있는 동안에는 빠르게 도는 것이 맞다.
+ */
+ public void recordBusy(WorkQueue queue) {
+ states.get(queue).recordBusy(nanoTime.getAsLong());
+ }
+
+ /** 이번 폴링이 빈손이었다. 다음 간격을 두 배로 늘린다(상한까지). */
+ public void recordIdle(WorkQueue queue) {
+ states.get(queue).recordIdle(nanoTime.getAsLong(), maxDelayMs);
+ }
+
+ /** 백오프를 즉시 풀어 다음 틱에 폴링하게 한다. */
+ public void wake(WorkQueue queue) {
+ states.get(queue).wake(nanoTime.getAsLong());
+ }
+
+ /**
+ * enqueue 가 커밋된 뒤 해당 큐의 워커를 깨운다.
+ *
+ * {@code fallbackExecution = true} 인 이유: 발행부가 항상 트랜잭션 안이라고 가정할 수
+ * 없다. 트랜잭션 밖에서 발행된 신호를 조용히 버리면, 그 큐는 백오프 상한만큼 늦게 깨어난다 —
+ * 사용자에게는 "눌렀는데 한참 아무 일도 안 일어남"으로 보인다. 밖이면 즉시 처리한다.
+ */
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
+ public void onWorkQueued(WorkQueuedEvent event) {
+ wake(event.queue());
+ }
+
+ /**
+ * 큐 하나의 백오프 상태. 갱신은 폴링 스레드(스케줄러)와 깨우기 스레드(요청·실행기) 양쪽에서
+ * 오지만 초당 몇 번 수준이라, 눈에 보이게 맞는 {@code synchronized} 를 쓴다 — 여기서 lock-free
+ * 로 얻을 것은 없고, 잃을 것(깨우기 유실)은 사용자에게 최대 30초로 보인다.
+ */
+ private static final class QueueState {
+
+ private final long baseDelayMs;
+ private long delayMs;
+ private long nextPollAtNanos;
+ private long generation;
+ private long polledGeneration;
+
+ private QueueState(long baseDelayMs, long now) {
+ this.baseDelayMs = baseDelayMs;
+ this.delayMs = baseDelayMs;
+ this.nextPollAtNanos = now; // 기동 직후 첫 틱은 무조건 폴링한다
+ }
+
+ private synchronized boolean beginPoll(long now) {
+ if (now - nextPollAtNanos < 0) {
+ return false;
+ }
+ polledGeneration = generation;
+ return true;
+ }
+
+ private synchronized void recordBusy(long now) {
+ delayMs = baseDelayMs;
+ nextPollAtNanos = now;
+ }
+
+ private synchronized void recordIdle(long now, long max) {
+ if (generation != polledGeneration) {
+ // 폴링이 도는 중에 깨우기가 왔다. 그 일을 이 폴링이 못 봤을 수 있으므로 백오프를
+ // 늘리지 않는다 — wake 가 이미 간격과 다음 시각을 최소로 돌려놨다.
+ return;
+ }
+ delayMs = Math.min(delayMs * 2, max);
+ nextPollAtNanos = now + TimeUnit.MILLISECONDS.toNanos(delayMs);
+ }
+
+ private synchronized void wake(long now) {
+ generation++;
+ delayMs = baseDelayMs;
+ nextPollAtNanos = now;
+ }
+ }
+}
diff --git a/src/main/java/com/example/dvely/deployment/application/command/DeploymentCommandService.java b/src/main/java/com/example/dvely/deployment/application/command/DeploymentCommandService.java
index 79499e7a..74d5f371 100644
--- a/src/main/java/com/example/dvely/deployment/application/command/DeploymentCommandService.java
+++ b/src/main/java/com/example/dvely/deployment/application/command/DeploymentCommandService.java
@@ -23,6 +23,7 @@
import com.example.dvely.deployment.application.service.DeploymentOutcomeService;
import com.example.dvely.deployment.domain.model.DeploymentHistory;
import com.example.dvely.deployment.domain.repository.DeploymentHistoryRepository;
+import com.example.dvely.deployment.infrastructure.worker.DeploymentExecutionRegistry;
import com.example.dvely.deployment.domain.value.DeployTargetType;
import com.example.dvely.deployment.domain.value.PackageManager;
import com.example.dvely.deployment.infrastructure.workflow.DeployWorkflowTemplate;
@@ -64,6 +65,9 @@ public class DeploymentCommandService {
private final GithubActionsPort githubActionsPort;
private final GithubRepoPort githubRepoPort;
private final DeploymentHistoryRepository deploymentHistoryRepository;
+ // #340 5-8: executeQueued 가 끝났음을 하트비트에 알리는 유일한 지점. 등록은 DeploymentRunWorker
+ // 가 제출 직전에 한다(그쪽 javadoc 참고) — 여기서 등록하면 executor 큐 대기 창이 비어버린다.
+ private final DeploymentExecutionRegistry deploymentExecutionRegistry;
// Track Z (#56) D1/§5.4: needed so a direct deploy can no longer silently merge preview into
// main once the result-approval gate owns that project (see prepareRelease's mergeAllowed).
private final ProjectApprovalPolicyRepository projectApprovalPolicyRepository;
@@ -233,6 +237,10 @@ public void executeQueued(Long historyId) {
execute(historyId);
} catch (Exception exception) {
handleExecutionFailure(historyId, exception);
+ } finally {
+ // #340 5-8: 이 배포는 더 이상 이 인스턴스가 돌리고 있지 않다 — 하트비트 대상에서
+ // 빼야 한다. 여기서 빼지 않으면 이미 끝난 이력의 리스를 30초마다 되살리게 된다.
+ deploymentExecutionRegistry.unregister(historyId);
}
}
diff --git a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java
index 4d1eeee8..ba372c96 100644
--- a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java
+++ b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java
@@ -3,6 +3,7 @@
import com.example.dvely.deployment.domain.model.DeploymentHistory;
import com.example.dvely.project.domain.value.DeployStatus;
import java.time.LocalDateTime;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -33,7 +34,32 @@ public interface DeploymentHistoryRepository {
List claimPending(String workerId, int limit);
+ /**
+ * 폴링 한 번이 하는 일 전부 — 만료 리스 회수와 claim 을 한 트랜잭션으로 묶는다(#340 5-1).
+ * 따로 부르면 폴링 한 번이 트랜잭션 두 개가 되고, 트랜잭션마다 붙는 {@code SET autocommit} ·
+ * {@code COMMIT} 의례가 유휴 DB 비용의 대부분이었다. 회수 UPDATE 가 같은 트랜잭션에서 먼저
+ * 반영되므로, 방금 회수된 행을 같은 폴링의 claim 이 곧바로 집는다.
+ */
+ List recoverAndClaimPending(String workerId, int limit);
+
void recoverExpiredLeases();
- void renewLeases(String workerId);
+ /**
+ * claim 해 놓고 실행기에 넘기지 못한 이력을 PENDING 으로 되돌린다(#340 5-2).
+ *
+ * 이 호출이 없으면 배치의 두 번째 이력이 IN_PROGRESS 인 채 리스 만료(2분)까지 방치된다 —
+ * 사용자 화면에는 "배포 중"으로 보이지만 그것을 실제로 돌리는 스레드는 어디에도 없다.
+ *
+ * @return 이 호출이 실제로 되돌렸으면 true. false 는 그 사이 다른 주체(리스 만료 회수 등)가
+ * 이미 이 행을 IN_PROGRESS 밖으로 옮겼다는 뜻이고, 그것도 정상이다.
+ */
+ boolean releaseClaim(Long historyId, String workerId, long backoffMillis);
+
+ /**
+ * 이 인스턴스가 실제로 실행 중인 이력들의 리스만 연장한다(#340 5-8).
+ *
+ * {@code historyIds} 는 호출자의 실행 레지스트리 스냅샷이다. 비어 있으면 호출자가 아예
+ * 부르지 않는다 — 실행 중인 배포가 없는데 0행짜리 UPDATE 를 30초마다 내보낼 이유가 없다.
+ */
+ void renewLeases(String workerId, Collection historyIds);
}
diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java
index e007bbc3..1e39f284 100644
--- a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java
@@ -2,13 +2,17 @@
import com.example.dvely.deployment.domain.model.DeploymentHistory;
import com.example.dvely.deployment.domain.repository.DeploymentHistoryRepository;
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkQueuedEvent;
import com.example.dvely.deployment.infrastructure.persistence.entity.DeploymentHistoryEntity;
import com.example.dvely.project.domain.value.DeployStatus;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.time.Duration;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@@ -18,9 +22,21 @@
public class DeploymentHistoryRepositoryAdapter implements DeploymentHistoryRepository {
private final SpringDataDeploymentHistoryRepository springDataRepository;
+ private final ApplicationEventPublisher eventPublisher;
@Override
public DeploymentHistory save(DeploymentHistory history) {
+ DeploymentHistory saved = doSave(history);
+ if (saved.getStatus() == DeployStatus.PENDING) {
+ // #340 5-1: 워커가 집을 수 있는 상태로 저장됐다 — 커밋 뒤에 워커를 깨운다. 이 신호가
+ // 없으면 유휴가 길어져 폴링 간격이 상한까지 늘어난 뒤 배포 버튼을 누른 사용자가 그
+ // 상한만큼 기다린다.
+ eventPublisher.publishEvent(new WorkQueuedEvent(WorkQueue.DEPLOYMENT_RUN));
+ }
+ return saved;
+ }
+
+ private DeploymentHistory doSave(DeploymentHistory history) {
if (history.getId() == null) {
DeploymentHistoryEntity entity = springDataRepository.save(DeploymentHistoryEntity.from(history));
return entity.toDomain();
@@ -99,6 +115,13 @@ public List claimPending(String workerId, int limit) {
.toList();
}
+ @Override
+ @Transactional
+ public List recoverAndClaimPending(String workerId, int limit) {
+ recoverExpiredLeases();
+ return claimPending(workerId, limit);
+ }
+
@Override
@Transactional
public void recoverExpiredLeases() {
@@ -118,11 +141,27 @@ public void recoverExpiredLeases() {
@Override
@Transactional
- public void renewLeases(String workerId) {
+ public boolean releaseClaim(Long historyId, String workerId, long backoffMillis) {
+ return springDataRepository.releaseClaim(
+ historyId,
+ workerId,
+ LocalDateTime.now().plus(Duration.ofMillis(backoffMillis)),
+ DeployStatus.IN_PROGRESS.name(),
+ DeployStatus.PENDING.name()
+ ) == 1;
+ }
+
+ @Override
+ @Transactional
+ public void renewLeases(String workerId, Collection historyIds) {
+ if (historyIds.isEmpty()) {
+ return;
+ }
springDataRepository.renewLeases(
workerId,
LocalDateTime.now().plusMinutes(2),
- DeployStatus.IN_PROGRESS.name()
+ DeployStatus.IN_PROGRESS.name(),
+ historyIds
);
}
}
diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java
index e47b346c..6689576c 100644
--- a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java
+++ b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java
@@ -2,6 +2,7 @@
import com.example.dvely.deployment.infrastructure.persistence.entity.DeploymentHistoryEntity;
import java.time.LocalDateTime;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
@@ -54,21 +55,52 @@ int claim(
@Param("runningStatus") String runningStatus
);
+ // #340 5-2: claim 된 이력을 실행기에 넘기지 못했을 때 PENDING 으로 되돌린다. claim 이 올린
+ // attempt 를 그대로 되돌리는 이유는, 실행기 포화가 이 배포의 실패가 아니라 시스템 부하로 인한
+ // 배압이기 때문이다 — 재시도 예산을 여기서 쓰면 사용자는 아무 잘못 없이 재시도 횟수를 잃는다
+ // (AgentRunWorker 의 ADR-Y3 releaseClaim 이 attempt 를 건드리지 않는 것과 같은 판단).
+ // WHERE 는 claim 과 같은 조건부 UPDATE 모양(id + status + lease_owner)이라, 이 claim 을 실제로
+ // 쥔 워커만 되돌릴 수 있고 동시에 도는 recoverExpiredLeases 와 이중 전이를 만들지 않는다.
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query("""
+ update DeploymentHistoryEntity history
+ set history.status = :pendingStatus,
+ history.attempt = history.attempt - 1,
+ history.nextRunAt = :nextRunAt,
+ history.leaseOwner = null,
+ history.leaseUntil = null
+ where history.id = :historyId
+ and history.status = :runningStatus
+ and history.leaseOwner = :workerId
+ """)
+ int releaseClaim(
+ @Param("historyId") Long historyId,
+ @Param("workerId") String workerId,
+ @Param("nextRunAt") LocalDateTime nextRunAt,
+ @Param("runningStatus") String runningStatus,
+ @Param("pendingStatus") String pendingStatus
+ );
+
List findByStatusAndLeaseUntilBefore(String status, LocalDateTime now);
List findByStatusAndLeaseUntilIsNullAndUpdatedAtBefore(
String status, LocalDateTime updatedBefore, Pageable pageable);
+ // #340 5-8: 갱신 범위를 호출자의 DeploymentExecutionRegistry 스냅샷(historyIds)으로 좁힌다.
+ // 이 워커가 claim 했지만 실행기가 거부한 이력의 리스를 여기서 계속 살려두면
+ // recoverExpiredLeases 가 그것을 영영 회수하지 못한다(AgentRunWorker 의 ADR-Y4 와 같은 이유).
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update DeploymentHistoryEntity history
set history.leaseUntil = :leaseUntil
where history.status = :runningStatus
and history.leaseOwner = :workerId
+ and history.id in :historyIds
""")
int renewLeases(
@Param("workerId") String workerId,
@Param("leaseUntil") LocalDateTime leaseUntil,
- @Param("runningStatus") String runningStatus
+ @Param("runningStatus") String runningStatus,
+ @Param("historyIds") Collection historyIds
);
}
diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/worker/DeploymentExecutionRegistry.java b/src/main/java/com/example/dvely/deployment/infrastructure/worker/DeploymentExecutionRegistry.java
new file mode 100644
index 00000000..d5b38889
--- /dev/null
+++ b/src/main/java/com/example/dvely/deployment/infrastructure/worker/DeploymentExecutionRegistry.java
@@ -0,0 +1,37 @@
+package com.example.dvely.deployment.infrastructure.worker;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import org.springframework.stereotype.Component;
+
+/**
+ * 이 인스턴스가 실제로 실행하기로 한 배포 이력 ID 의 JVM 로컬 레지스트리(#340 5-8).
+ *
+ * {@code DeploymentRunWorker#renewLeases} 의 하트비트가 이 집합으로 범위를 좁힌다. 예전에는
+ * "이 workerId 가 소유한 IN_PROGRESS 행 전부"를 조건 없이 갱신했고, 그래서 두 가지가 함께
+ * 나빴다 — (1) 실행 중인 배포가 하나도 없어도 30초마다 0행짜리 UPDATE 가 나갔고, (2) 실행기에
+ * 넘기지 못해 실행 주체가 없는 행의 리스까지 계속 살려두면 {@code recoverExpiredLeases} 가
+ * 그것을 영영 회수하지 못한다. {@code AgentRunWorker} 가 #55(ADR-Y4)에서 고친 것과 같은 형태다.
+ *
+ * 등록은 executor 제출 직전에 해야 한다. 제출은 됐지만 아직 executor 큐에서 대기 중인
+ * 배포도 하트비트 보호를 받아야 하기 때문이다 — 그 창을 비워두면 리스가 만료돼 회수가 같은
+ * 일을 다시 집는데, 원래 제출분은 나중에 그대로 실행된다(이중 실행).
+ */
+@Component
+public class DeploymentExecutionRegistry {
+
+ private final Set registeredHistoryIds = ConcurrentHashMap.newKeySet();
+
+ public void register(Long historyId) {
+ registeredHistoryIds.add(historyId);
+ }
+
+ public void unregister(Long historyId) {
+ registeredHistoryIds.remove(historyId);
+ }
+
+ /** 하트비트의 {@code history_id IN (...)} 필터에 쓸 시점 스냅샷. */
+ public Set snapshot() {
+ return Set.copyOf(registeredHistoryIds);
+ }
+}
diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/worker/DeploymentRunWorker.java b/src/main/java/com/example/dvely/deployment/infrastructure/worker/DeploymentRunWorker.java
index 5b907b75..a3671f8c 100644
--- a/src/main/java/com/example/dvely/deployment/infrastructure/worker/DeploymentRunWorker.java
+++ b/src/main/java/com/example/dvely/deployment/infrastructure/worker/DeploymentRunWorker.java
@@ -1,35 +1,110 @@
package com.example.dvely.deployment.infrastructure.worker;
import com.example.dvely.deployment.application.command.DeploymentCommandService;
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkerPollGate;
import com.example.dvely.deployment.domain.repository.DeploymentHistoryRepository;
import java.lang.management.ManagementFactory;
-import lombok.RequiredArgsConstructor;
+import java.util.List;
+import java.util.Set;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
+/**
+ * PENDING 배포를 집어 {@link DeploymentCommandService#executeQueued}(비동기) 로 넘긴다.
+ *
+ * #340 5-2: 위임을 이력 하나씩 격리한다. 예전에는 루프가 통째로 노출돼 있어,
+ * {@code deploymentExecutor} 가 포화된 순간 {@code @Async} 프록시가 던지는
+ * {@code TaskRejectedException} 하나가 루프를 끊었다. 그러면 같은 배치에서 이미 claim 된
+ * 다음 이력이 IN_PROGRESS 인 채로 남고, 그것을 돌리는 스레드는 어디에도 없으며, 리스 만료(2분)
+ * 전까지는 아무도 회수하지 않는다. 사용자 화면에는 그 2분 동안 "배포 중"이 떠 있다.
+ * {@code AgentRunWorker} 가 #55(ADR-Y3)에서 이미 고친 것과 같은 버그가 여기에만 남아 있었다.
+ */
@Slf4j
@Component
-@RequiredArgsConstructor
public class DeploymentRunWorker {
private static final int CLAIM_BATCH_SIZE = 2;
private final DeploymentHistoryRepository deploymentHistoryRepository;
private final DeploymentCommandService deploymentCommandService;
+ private final WorkerPollGate pollGate;
+ private final DeploymentExecutionRegistry executionRegistry;
+ private final long dispatchRejectBackoffMs;
private final String workerId = ManagementFactory.getRuntimeMXBean().getName() + "-deployment";
+ public DeploymentRunWorker(DeploymentHistoryRepository deploymentHistoryRepository,
+ DeploymentCommandService deploymentCommandService,
+ WorkerPollGate pollGate,
+ DeploymentExecutionRegistry executionRegistry,
+ @Value("${qeploy.deployment.worker.dispatch-reject-backoff-ms:5000}")
+ long dispatchRejectBackoffMs) {
+ this.deploymentHistoryRepository = deploymentHistoryRepository;
+ this.deploymentCommandService = deploymentCommandService;
+ this.pollGate = pollGate;
+ this.executionRegistry = executionRegistry;
+ this.dispatchRejectBackoffMs = dispatchRejectBackoffMs;
+ }
+
@Scheduled(fixedDelayString = "${qeploy.deployment.worker.poll-interval-ms:1000}")
public void dispatchPendingDeployments() {
- deploymentHistoryRepository.recoverExpiredLeases();
- for (Long historyId : deploymentHistoryRepository.claimPending(workerId, CLAIM_BATCH_SIZE)) {
+ // #340 5-1: 틱은 1초마다 오지만, 일이 없는 동안에는 DB 를 치지 않는다.
+ if (!pollGate.shouldPoll(WorkQueue.DEPLOYMENT_RUN)) {
+ return;
+ }
+ List historyIds;
+ try {
+ historyIds = deploymentHistoryRepository.recoverAndClaimPending(workerId, CLAIM_BATCH_SIZE);
+ } catch (RuntimeException exception) {
+ // DB 가 흔들리는 동안 매초 같은 쿼리를 던져봐야 소용이 없다 — 물러나며 재시도한다.
+ pollGate.recordIdle(WorkQueue.DEPLOYMENT_RUN);
+ throw exception;
+ }
+ if (historyIds.isEmpty()) {
+ pollGate.recordIdle(WorkQueue.DEPLOYMENT_RUN);
+ } else {
+ pollGate.recordBusy(WorkQueue.DEPLOYMENT_RUN);
+ }
+ for (Long historyId : historyIds) {
+ dispatchOne(historyId);
+ }
+ }
+
+ private void dispatchOne(Long historyId) {
+ // #340 5-8: 제출 "직전"에 등록한다. 제출은 됐지만 아직 executor 큐에서 대기 중인 배포도
+ // 하트비트 보호를 받아야 한다 — 그 창을 비워두면 리스가 만료돼 회수가 같은 일을 다시
+ // 집는데 원래 제출분은 나중에 그대로 실행된다.
+ executionRegistry.register(historyId);
+ try {
log.info("배포 Job 실행 위임: historyId={} workerId={}", historyId, workerId);
deploymentCommandService.executeQueued(historyId);
+ } catch (RuntimeException exception) {
+ // 실행기 포화(TaskRejectedException)든 제출 전 다른 예외든 결론은 같다 — 이 이력은
+ // 돌지 않는다. 그렇다면 claim 을 붙들고 있을 이유가 없으므로 즉시 PENDING 으로
+ // 되돌린다. 예외를 밖으로 흘리지 않는 것이 핵심이다: 흘리면 이 배치의 나머지가
+ // claim 된 채 버려진다.
+ handleDispatchFailure(historyId, exception);
}
}
+ private void handleDispatchFailure(Long historyId, RuntimeException exception) {
+ executionRegistry.unregister(historyId);
+ boolean released = deploymentHistoryRepository.releaseClaim(
+ historyId, workerId, dispatchRejectBackoffMs);
+ log.warn("배포 Job 위임 실패 — 재대기열로 반환합니다. historyId={} workerId={} released={} 원인={}",
+ historyId, workerId, released, exception.toString());
+ }
+
@Scheduled(fixedDelayString = "${qeploy.deployment.worker.heartbeat-interval-ms:30000}")
public void renewLeases() {
- deploymentHistoryRepository.renewLeases(workerId);
+ Set registered = executionRegistry.snapshot();
+ if (registered.isEmpty()) {
+ // #340 5-8: 이 JVM 이 지금 실제로 돌리고 있는 배포가 없다 — 쿼리를 아예 내지 않는다.
+ // (AgentRunWorker 는 이미 이렇게 하고 있었고, 이쪽만 조건 없이 UPDATE 를 내보냈다.)
+ return;
+ }
+ deploymentHistoryRepository.renewLeases(workerId, registered);
}
}
diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/worker/StuckDeploymentRecoveryWorker.java b/src/main/java/com/example/dvely/deployment/infrastructure/worker/StuckDeploymentRecoveryWorker.java
index f8e9172a..cabe490d 100644
--- a/src/main/java/com/example/dvely/deployment/infrastructure/worker/StuckDeploymentRecoveryWorker.java
+++ b/src/main/java/com/example/dvely/deployment/infrastructure/worker/StuckDeploymentRecoveryWorker.java
@@ -1,6 +1,7 @@
package com.example.dvely.deployment.infrastructure.worker;
import com.example.dvely.auth.application.command.AuthCommandService;
+import com.example.dvely.common.worker.NextCheckSchedule;
import com.example.dvely.auth.domain.model.User;
import com.example.dvely.auth.domain.repository.UserRepository;
import com.example.dvely.deployment.application.port.out.GithubActionsPort;
@@ -12,6 +13,7 @@
import com.example.dvely.deployment.infrastructure.workflow.DeployWorkflowTemplate;
import com.example.dvely.project.domain.model.Project;
import com.example.dvely.project.domain.repository.ProjectRepository;
+import java.time.Duration;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -44,16 +46,35 @@ public class StuckDeploymentRecoveryWorker {
private final DeploymentOutcomeService deploymentOutcomeService;
private final StuckDeploymentRecoveryProperties properties;
+ /**
+ * 판정을 못 얻은 이력별 재조회 간격(#340 5-4). 예전에는 멈춘 배포 한 건이 포기 시각(기본
+ * 120분)에 닿을 때까지 매분 GitHub 을 쳤다 — 한 건당 최대 120회다. runId 가 없는
+ * 이력은 그때마다 목록 조회(per_page=30)까지 돌았다.
+ *
+ * GitHub Actions 실행 상태는 초 단위로 바뀌지 않는다. 첫 몇 번은 촘촘히 보되(방금 끝났을
+ * 수 있다) 계속 결론이 없으면 물러난다 — 1 → 2 → 5 → 10분. 판정이 나면 장부에서 지운다.
+ */
+ private final NextCheckSchedule recheckSchedule = new NextCheckSchedule<>();
+
+ /** 결론 없는 재조회의 간격 단계(분). 마지막 값에 닿으면 그 값을 유지한다. */
+ private static final long[] RECHECK_BACKOFF_MINUTES = {1L, 2L, 5L, 10L};
+
@Scheduled(fixedDelayString = "${qeploy.deployment.recovery.poll-interval-ms:60000}")
public void recoverStuckDeployments() {
LocalDateTime graceCutoff = LocalDateTime.now().minusMinutes(properties.graceMinutesOrDefault());
for (DeploymentHistory history : deploymentHistoryRepository.findDispatchedAwaitingOutcome(
graceCutoff, properties.batchSizeOrDefault())) {
+ if (!recheckSchedule.due(history.getId())) {
+ continue; // #340 5-4: 아직 다시 물을 때가 아니다 — GitHub 호출을 아끼는 유일한 지점
+ }
try {
recover(history);
} catch (RuntimeException exception) {
// 한 이력의 실패가 나머지를 막지 않는다. GitHub 호출은 레이트 리밋·토큰 만료로
- // 언제든 실패할 수 있고 다음 주기에 다시 물으면 되는 성격이다.
+ // 언제든 실패할 수 있고 다음 주기에 다시 물으면 되는 성격이다. 실패도 "판정을 못
+ // 얻은 것"이므로 같은 백오프를 태운다 — 레이트 리밋에 걸린 상태로 매분 다시 치는
+ // 것이 가장 나쁘다.
+ backOffRecheck(history.getId());
log.warn("멈춘 배포 회수 실패 — 다음 주기에 재시도: historyId={} 원인={}",
history.getId(), exception.toString());
}
@@ -65,6 +86,8 @@ private void recover(DeploymentHistory history) {
if (project == null) {
log.warn("멈춘 배포의 프로젝트가 없음: historyId={} projectId={}",
history.getId(), history.getProjectId());
+ // 프로젝트가 없으면 다음 주기에도 없다 — 매분 다시 확인할 이유가 없다.
+ backOffRecheck(history.getId());
return;
}
@@ -80,12 +103,24 @@ private void recover(DeploymentHistory history) {
if ("success".equals(run.conclusion())) {
log.info("멈춘 배포 회수 — 성공으로 확정: historyId={} runId={}", history.getId(), run.runId());
deploymentOutcomeService.applySuccess(history, project);
+ recheckSchedule.clear(history.getId());
return;
}
log.info("멈춘 배포 회수 — 실패로 확정: historyId={} runId={} conclusion={}",
history.getId(), run.runId(), run.conclusion());
deploymentOutcomeService.applyFailure(history, project,
DeployFailureCode.WORKFLOW_FAILED, "GitHub Actions workflow conclusion: " + run.conclusion());
+ recheckSchedule.clear(history.getId());
+ }
+
+ /**
+ * 이 이력을 다음에 언제 다시 물을지 미룬다(#340 5-4). 결론 없는 조회가 이어질수록 간격이
+ * 1 → 2 → 5 → 10분으로 늘어난다.
+ */
+ private void backOffRecheck(Long historyId) {
+ int checks = recheckSchedule.inconclusiveChecks(historyId);
+ long minutes = RECHECK_BACKOFF_MINUTES[Math.min(checks, RECHECK_BACKOFF_MINUTES.length - 1)];
+ recheckSchedule.scheduleAfter(historyId, Duration.ofMinutes(minutes).toMillis());
}
/**
@@ -124,12 +159,15 @@ private GithubActionsPort.WorkflowRunStatus readRunStatus(DeploymentHistory hist
private void abandonIfHopeless(DeploymentHistory history, Project project, String reason) {
LocalDateTime abandonCutoff = LocalDateTime.now().minusMinutes(properties.abandonMinutesOrDefault());
if (history.getUpdatedAt() == null || history.getUpdatedAt().isAfter(abandonCutoff)) {
+ // 아직 포기할 때가 아니다 = 이번 조회는 판정을 못 얻었다. 다음 조회를 미룬다.
+ backOffRecheck(history.getId());
return;
}
log.warn("멈춘 배포 회수 포기 — 결과 미확인으로 닫는다: historyId={} runId={} 사유={}",
history.getId(), history.getWorkflowRunId(), reason);
deploymentOutcomeService.applyFailure(history, project,
DeployFailureCode.RESULT_UNKNOWN, "배포 결과를 확인할 수 없습니다. " + reason);
+ recheckSchedule.clear(history.getId());
}
private String resolveUserToken(Long ownerUserId) {
diff --git a/src/main/java/com/example/dvely/domainbinding/infrastructure/worker/DomainVerificationWorker.java b/src/main/java/com/example/dvely/domainbinding/infrastructure/worker/DomainVerificationWorker.java
index d35f2f44..7cb81256 100644
--- a/src/main/java/com/example/dvely/domainbinding/infrastructure/worker/DomainVerificationWorker.java
+++ b/src/main/java/com/example/dvely/domainbinding/infrastructure/worker/DomainVerificationWorker.java
@@ -1,14 +1,17 @@
package com.example.dvely.domainbinding.infrastructure.worker;
+import com.example.dvely.common.worker.NextCheckSchedule;
import com.example.dvely.domainbinding.application.command.DomainBindingCommandService;
import com.example.dvely.domainbinding.domain.model.DomainBinding;
import com.example.dvely.domainbinding.domain.repository.DomainBindingRepository;
import com.example.dvely.domainbinding.domain.value.DomainStatus;
import com.example.dvely.domainbinding.domain.value.DomainType;
import com.example.dvely.domainbinding.infrastructure.config.DomainVerificationProperties;
+import java.time.Duration;
import java.time.LocalDateTime;
-import lombok.RequiredArgsConstructor;
+import java.util.concurrent.Executor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -27,7 +30,6 @@
*/
@Slf4j
@Component
-@RequiredArgsConstructor
public class DomainVerificationWorker {
// CONNECTED 후 HTTPS 뱃지(httpsEnforced)를 채우려 재검증하는 창. EC2(Caddy on-demand TLS)는 첫 https
@@ -35,9 +37,42 @@ public class DomainVerificationWorker {
// 보내 warming)으로 곧 true 가 된다. 무한 프로브를 막으려 생성 후 이 창 안에서만 재검증한다(#6).
private static final int HTTPS_RECHECK_WINDOW_MINUTES = 30;
+ // #340 5-5: 생성 후 경과 시간별 재검증 간격. 갓 만든 도메인은 곧 붙을 수 있으니 촘촘히 보고,
+ // 한 시간이 지나도 안 붙었으면 사용자가 registrar 설정을 아직 안 했을 가능성이 높다 —
+ // 그때부터는 성기게 본다.
+ private static final Duration YOUNG_DOMAIN_WINDOW = Duration.ofMinutes(10);
+ private static final Duration YOUNG_DOMAIN_INTERVAL = Duration.ofMinutes(1);
+ private static final Duration MIDDLE_AGED_DOMAIN_WINDOW = Duration.ofHours(1);
+ private static final Duration MIDDLE_AGED_DOMAIN_INTERVAL = Duration.ofMinutes(5);
+ private static final Duration OLD_DOMAIN_INTERVAL = Duration.ofMinutes(10);
+
private final DomainBindingRepository domainBindingRepository;
private final DomainBindingCommandService domainBindingCommandService;
private final DomainVerificationProperties properties;
+ private final Executor verificationExecutor;
+
+ /**
+ * 도메인별 재검증 간격(#340 5-5). 예전에는 배치 전체를 매 주기(60초) 검증했다 —
+ * 커스텀 도메인은 TTL 이 1440분이므로 한 건당 최대 1,440회, 그것도 Cloudflare + GitHub +
+ * HTTPS 프로브 세 묶음이다.
+ *
+ * 기다리는 대상이 DNS 전파와 호스팅 반영이라 시간이 지날수록 "다음 1분 안에 바뀔"
+ * 확률이 떨어진다. 그래서 갓 만든 도메인은 촘촘히, 오래된 도메인은 성기게 본다.
+ */
+ private final NextCheckSchedule verifySchedule = new NextCheckSchedule<>();
+
+ /** CONNECTED 후 HTTPS 뱃지 재검증도 같은 이유로 간격을 둔다 — 위와 키가 겹치면 안 되므로 따로 둔다. */
+ private final NextCheckSchedule httpsRecheckSchedule = new NextCheckSchedule<>();
+
+ public DomainVerificationWorker(DomainBindingRepository domainBindingRepository,
+ DomainBindingCommandService domainBindingCommandService,
+ DomainVerificationProperties properties,
+ @Qualifier("domainVerificationExecutor") Executor verificationExecutor) {
+ this.domainBindingRepository = domainBindingRepository;
+ this.domainBindingCommandService = domainBindingCommandService;
+ this.properties = properties;
+ this.verificationExecutor = verificationExecutor;
+ }
@Scheduled(fixedDelayString = "${qeploy.domain-binding.verification.poll-interval-ms:60000}")
public void verifyPendingDomains() {
@@ -49,8 +84,13 @@ public void verifyPendingDomains() {
}
if (isExpired(domain)) {
abandon(domain);
+ verifySchedule.clear(domain.getId());
continue;
}
+ if (!verifySchedule.due(domain.getId())) {
+ continue; // #340 5-5: 아직 다시 볼 때가 아니다 — 외부 API 호출을 아끼는 지점
+ }
+ verifySchedule.scheduleAfter(domain.getId(), recheckIntervalFor(domain).toMillis());
verify(domain);
}
recheckHttpsForConnectedDomains();
@@ -71,13 +111,19 @@ private void recheckHttpsForConnectedDomains() {
if (!withinHttpsRecheckWindow(domain)) {
continue; // 창을 지났는데도 https 미확인 — 수동 재검증에 맡긴다(무한 프로브 방지).
}
- try {
- // verify() 와 달리 "검증 완료" 로그를 남기지 않는다 — 이미 CONNECTED 라 매 주기 스팸이 된다.
- domainBindingCommandService.checkVerificationAsSystem(domain.getId());
- } catch (RuntimeException exception) {
- log.debug("HTTPS 재검증 실패(다음 주기 재시도): domainId={} 원인={}",
- domain.getId(), exception.toString());
+ if (!httpsRecheckSchedule.due(domain.getId())) {
+ continue;
}
+ httpsRecheckSchedule.scheduleAfter(domain.getId(), recheckIntervalFor(domain).toMillis());
+ submit(() -> {
+ try {
+ // verify() 와 달리 "검증 완료" 로그를 남기지 않는다 — 이미 CONNECTED 라 매 주기 스팸이 된다.
+ domainBindingCommandService.checkVerificationAsSystem(domain.getId());
+ } catch (RuntimeException exception) {
+ log.debug("HTTPS 재검증 실패(다음 주기 재시도): domainId={} 원인={}",
+ domain.getId(), exception.toString());
+ }
+ }, domain);
}
}
@@ -87,20 +133,59 @@ private boolean withinHttpsRecheckWindow(DomainBinding domain) {
&& createdAt.plusMinutes(HTTPS_RECHECK_WINDOW_MINUTES).isAfter(LocalDateTime.now());
}
+ /**
+ * #340 5-5: 검증 자체를 전용 executor 로 넘긴다. Cloudflare + GitHub + HTTPS 프로브 세 묶음이
+ * 배치 20건만큼 직렬로 스케줄러 스레드를 붙들고 있었고, 한 건이 타임아웃까지 버티면 그
+ * 시간이 그대로 스케줄러 점유가 됐다 — 같은 풀을 쓰는 다른 잡까지 함께 밀린다.
+ */
private void verify(DomainBinding domain) {
- try {
- // 한 도메인의 실패가 나머지를 막지 않아야 한다. Cloudflare·GitHub 호출이 섞여 있어
- // 어느 하나는 언제든 실패할 수 있고, 다음 주기에 다시 시도하면 되는 성격이다.
- DomainStatus status = domainBindingCommandService
- .checkVerificationAsSystem(domain.getId())
- .status();
- if (status == DomainStatus.CONNECTED) {
- log.info("도메인 검증 완료: domainId={} hostname={}", domain.getId(), domain.getHostname());
+ submit(() -> {
+ try {
+ // 한 도메인의 실패가 나머지를 막지 않아야 한다. Cloudflare·GitHub 호출이 섞여 있어
+ // 어느 하나는 언제든 실패할 수 있고, 다음 주기에 다시 시도하면 되는 성격이다.
+ DomainStatus status = domainBindingCommandService
+ .checkVerificationAsSystem(domain.getId())
+ .status();
+ if (status == DomainStatus.CONNECTED) {
+ log.info("도메인 검증 완료: domainId={} hostname={}", domain.getId(), domain.getHostname());
+ verifySchedule.clear(domain.getId());
+ }
+ } catch (RuntimeException exception) {
+ log.warn("도메인 검증 실패 — 다음 주기에 재시도: domainId={} hostname={} 원인={}",
+ domain.getId(), domain.getHostname(), exception.toString());
}
+ }, domain);
+ }
+
+ /**
+ * executor 제출 자체가 실패해도(포화) 이 도메인의 이번 차례만 건너뛴다. 검증에는 claim 이
+ * 없어 되돌릴 상태가 없다 — 다음 차례에 다시 집으면 그만이다. 다만 간격은 이미 잡혔으므로
+ * "바로 다음 주기"가 아니라 그 간격 뒤에 다시 온다는 점을 알고 넘긴다: 포화는 우리가 이미
+ * 많이 돌고 있다는 뜻이라, 그 상황에서 서둘러 재시도하는 것이 옳지 않다.
+ */
+ private void submit(Runnable task, DomainBinding domain) {
+ try {
+ verificationExecutor.execute(task);
} catch (RuntimeException exception) {
- log.warn("도메인 검증 실패 — 다음 주기에 재시도: domainId={} hostname={} 원인={}",
- domain.getId(), domain.getHostname(), exception.toString());
+ log.warn("도메인 검증 위임 실패 — 다음 차례에 재시도: domainId={} 원인={}",
+ domain.getId(), exception.toString());
+ }
+ }
+
+ /** 생성 후 경과 시간이 길수록 성기게 본다. createdAt 이 없으면 가장 촘촘한 간격을 쓴다. */
+ private Duration recheckIntervalFor(DomainBinding domain) {
+ LocalDateTime createdAt = domain.getCreatedAt();
+ if (createdAt == null) {
+ return YOUNG_DOMAIN_INTERVAL;
+ }
+ Duration age = Duration.between(createdAt, LocalDateTime.now());
+ if (age.compareTo(YOUNG_DOMAIN_WINDOW) <= 0) {
+ return YOUNG_DOMAIN_INTERVAL;
+ }
+ if (age.compareTo(MIDDLE_AGED_DOMAIN_WINDOW) <= 0) {
+ return MIDDLE_AGED_DOMAIN_INTERVAL;
}
+ return OLD_DOMAIN_INTERVAL;
}
private void abandon(DomainBinding domain) {
diff --git a/src/main/java/com/example/dvely/webhook/domain/repository/WebhookDeliveryRepository.java b/src/main/java/com/example/dvely/webhook/domain/repository/WebhookDeliveryRepository.java
index ed1077db..16c13b0d 100644
--- a/src/main/java/com/example/dvely/webhook/domain/repository/WebhookDeliveryRepository.java
+++ b/src/main/java/com/example/dvely/webhook/domain/repository/WebhookDeliveryRepository.java
@@ -15,6 +15,14 @@ public interface WebhookDeliveryRepository {
List claimPending(String workerId, int limit);
+ /**
+ * 폴링 한 번이 하는 일 전부 — 만료 리스 회수와 claim 을 한 트랜잭션으로 묶는다(#340 5-1).
+ * 따로 부르면 폴링 한 번이 트랜잭션 두 개가 되고, 트랜잭션마다 붙는 {@code SET autocommit} ·
+ * {@code COMMIT} 의례가 유휴 DB 비용의 대부분이었다. 회수 UPDATE 가 같은 트랜잭션에서 먼저
+ * 반영되므로, 방금 회수된 행을 같은 폴링의 claim 이 곧바로 집는다.
+ */
+ List recoverAndClaimPending(String workerId, int limit);
+
void recoverExpiredLeases();
/**
@@ -31,4 +39,11 @@ public interface WebhookDeliveryRepository {
* 호출자는 0 이 돌아올 때까지 반복한다.
*/
int deleteTerminalBatch(LocalDateTime cutoff, int batchSize);
+
+ /**
+ * claim 해 놓고 executor 에 넘기지 못한 배달을 PENDING 으로 되돌린다(#340 5-3).
+ *
+ * @return 이 호출이 실제로 되돌렸으면 true.
+ */
+ boolean releaseClaim(String deliveryId, String workerId, long backoffMillis);
}
diff --git a/src/main/java/com/example/dvely/webhook/infrastructure/persistence/repository/SpringDataWebhookDeliveryRepository.java b/src/main/java/com/example/dvely/webhook/infrastructure/persistence/repository/SpringDataWebhookDeliveryRepository.java
index aacf97f1..dc759d33 100644
--- a/src/main/java/com/example/dvely/webhook/infrastructure/persistence/repository/SpringDataWebhookDeliveryRepository.java
+++ b/src/main/java/com/example/dvely/webhook/infrastructure/persistence/repository/SpringDataWebhookDeliveryRepository.java
@@ -45,6 +45,30 @@ int claim(
@Param("processingStatus") String processingStatus
);
+ // #340 5-3: claim 한 배달을 executor 에 넘기지 못했을 때 되돌린다. claim 이 올린 attempt 를
+ // 그대로 되돌리는 이유는 실행기 포화가 이 배달의 실패가 아니기 때문이다 — 재시도 예산을
+ // 여기서 쓰면 GitHub 이 다시 보내주지 않는 배달을 우리가 스스로 버리게 된다. WHERE 는 claim 과
+ // 같은 조건부 UPDATE 모양이라 이 claim 을 실제로 쥔 워커만 되돌린다.
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query("""
+ update WebhookDeliveryEntity delivery
+ set delivery.status = :pendingStatus,
+ delivery.attempt = delivery.attempt - 1,
+ delivery.nextAttemptAt = :nextAttemptAt,
+ delivery.leaseOwner = null,
+ delivery.leaseUntil = null
+ where delivery.id = :deliveryId
+ and delivery.status = :processingStatus
+ and delivery.leaseOwner = :workerId
+ """)
+ int releaseClaim(
+ @Param("deliveryId") String deliveryId,
+ @Param("workerId") String workerId,
+ @Param("nextAttemptAt") LocalDateTime nextAttemptAt,
+ @Param("processingStatus") String processingStatus,
+ @Param("pendingStatus") String pendingStatus
+ );
+
List findByStatusAndLeaseUntilBefore(String status, LocalDateTime now);
/**
diff --git a/src/main/java/com/example/dvely/webhook/infrastructure/persistence/repository/WebhookDeliveryRepositoryAdapter.java b/src/main/java/com/example/dvely/webhook/infrastructure/persistence/repository/WebhookDeliveryRepositoryAdapter.java
index fd68d894..f45ebb22 100644
--- a/src/main/java/com/example/dvely/webhook/infrastructure/persistence/repository/WebhookDeliveryRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/webhook/infrastructure/persistence/repository/WebhookDeliveryRepositoryAdapter.java
@@ -1,13 +1,17 @@
package com.example.dvely.webhook.infrastructure.persistence.repository;
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkQueuedEvent;
import com.example.dvely.webhook.domain.model.WebhookDelivery;
import com.example.dvely.webhook.domain.repository.WebhookDeliveryRepository;
import com.example.dvely.webhook.domain.value.WebhookDeliveryStatus;
import com.example.dvely.webhook.infrastructure.persistence.entity.WebhookDeliveryEntity;
+import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;
@@ -38,6 +42,7 @@ public class WebhookDeliveryRepositoryAdapter implements WebhookDeliveryReposito
);
private final SpringDataWebhookDeliveryRepository springDataRepository;
+ private final ApplicationEventPublisher eventPublisher;
@Override
public boolean enqueue(WebhookDelivery delivery) {
@@ -46,6 +51,9 @@ public boolean enqueue(WebhookDelivery delivery) {
}
try {
springDataRepository.saveAndFlush(WebhookDeliveryEntity.from(delivery));
+ // #340 5-1: 새 배달이 큐에 들어갔다 — 커밋 뒤에 워커를 깨운다. GitHub 은 응답이 늦으면
+ // 재전송하므로, 유휴 백오프가 늘어난 상태에서 배달이 상한만큼 방치되면 안 된다.
+ eventPublisher.publishEvent(new WorkQueuedEvent(WorkQueue.WEBHOOK_DELIVERY));
return true;
} catch (DataIntegrityViolationException exception) {
return false;
@@ -87,6 +95,25 @@ public List claimPending(String workerId, int limit) {
.toList();
}
+ @Override
+ @Transactional
+ public boolean releaseClaim(String deliveryId, String workerId, long backoffMillis) {
+ return springDataRepository.releaseClaim(
+ deliveryId,
+ workerId,
+ LocalDateTime.now().plus(Duration.ofMillis(backoffMillis)),
+ WebhookDeliveryStatus.PROCESSING.name(),
+ WebhookDeliveryStatus.PENDING.name()
+ ) == 1;
+ }
+
+ @Override
+ @Transactional
+ public List recoverAndClaimPending(String workerId, int limit) {
+ recoverExpiredLeases();
+ return claimPending(workerId, limit);
+ }
+
@Override
@Transactional
public void recoverExpiredLeases() {
diff --git a/src/main/java/com/example/dvely/webhook/infrastructure/worker/WebhookDeliveryWorker.java b/src/main/java/com/example/dvely/webhook/infrastructure/worker/WebhookDeliveryWorker.java
index 0cfe2e2d..1afc1e2a 100644
--- a/src/main/java/com/example/dvely/webhook/infrastructure/worker/WebhookDeliveryWorker.java
+++ b/src/main/java/com/example/dvely/webhook/infrastructure/worker/WebhookDeliveryWorker.java
@@ -1,30 +1,105 @@
package com.example.dvely.webhook.infrastructure.worker;
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkerPollGate;
import com.example.dvely.webhook.application.WebhookService;
import com.example.dvely.webhook.domain.repository.WebhookDeliveryRepository;
import java.lang.management.ManagementFactory;
-import lombok.RequiredArgsConstructor;
+import java.util.List;
+import java.util.concurrent.Executor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
+/**
+ * PENDING·RETRY_WAIT 웹훅 배달을 집어 전용 executor 로 넘긴다.
+ *
+ * #340 5-3: 예전에는 배달 처리를 스케줄러 스레드에서 동기로 돌렸다. 핸들러가 GitHub API
+ * 를 호출하므로 한 배달이 느리면 그 동안 이 워커의 다음 폴링이 통째로 밀리고, 스케줄러 풀을
+ * 공유하는 다른 잡까지 함께 굶었다. 한 폴링이 최대 10건을 직렬로 처리했으므로 최악은 그 10배다.
+ *
+ * 비동기로 넘기면 {@code TaskRejectedException} 이라는 새 실패 경로가 생긴다 — 그래서 배달
+ * 하나씩 격리하고, 거부되면 claim 을 즉시 PENDING 으로 되돌린다({@code DeploymentRunWorker} ·
+ * {@code AgentRunWorker} 와 같은 ADR-Y3 패턴). 되돌리지 않으면 그 배달은 리스 만료(2분)까지
+ * PROCESSING 인 채 남고, GitHub 은 우리가 200 을 이미 준 배달을 다시 보내주지 않는다.
+ */
@Slf4j
@Component
-@RequiredArgsConstructor
public class WebhookDeliveryWorker {
private static final int CLAIM_BATCH_SIZE = 10;
private final WebhookDeliveryRepository webhookDeliveryRepository;
private final WebhookService webhookService;
+ private final WorkerPollGate pollGate;
+ private final Executor webhookExecutor;
+ private final long dispatchRejectBackoffMs;
private final String workerId = ManagementFactory.getRuntimeMXBean().getName() + "-webhook";
+ public WebhookDeliveryWorker(WebhookDeliveryRepository webhookDeliveryRepository,
+ WebhookService webhookService,
+ WorkerPollGate pollGate,
+ @Qualifier("webhookExecutor") Executor webhookExecutor,
+ @Value("${qeploy.webhook.worker.dispatch-reject-backoff-ms:5000}")
+ long dispatchRejectBackoffMs) {
+ this.webhookDeliveryRepository = webhookDeliveryRepository;
+ this.webhookService = webhookService;
+ this.pollGate = pollGate;
+ this.webhookExecutor = webhookExecutor;
+ this.dispatchRejectBackoffMs = dispatchRejectBackoffMs;
+ }
+
@Scheduled(fixedDelayString = "${qeploy.webhook.worker.poll-interval-ms:1000}")
public void dispatchPendingDeliveries() {
- webhookDeliveryRepository.recoverExpiredLeases();
- for (String deliveryId : webhookDeliveryRepository.claimPending(workerId, CLAIM_BATCH_SIZE)) {
- log.info("webhook delivery 처리: deliveryId={} workerId={}", deliveryId, workerId);
+ // #340 5-1: 틱은 1초마다 오지만, 일이 없는 동안에는 DB 를 치지 않는다. 새 배달이 들어오면
+ // enqueue 커밋 뒤에 오는 깨우기 신호가 백오프를 즉시 풀어, 다음 틱(≤1초)에 집힌다.
+ if (!pollGate.shouldPoll(WorkQueue.WEBHOOK_DELIVERY)) {
+ return;
+ }
+ List deliveryIds;
+ try {
+ deliveryIds = webhookDeliveryRepository.recoverAndClaimPending(workerId, CLAIM_BATCH_SIZE);
+ } catch (RuntimeException exception) {
+ // DB 가 흔들리는 동안 매초 같은 쿼리를 던져봐야 소용이 없다 — 물러나며 재시도한다.
+ pollGate.recordIdle(WorkQueue.WEBHOOK_DELIVERY);
+ throw exception;
+ }
+ if (deliveryIds.isEmpty()) {
+ pollGate.recordIdle(WorkQueue.WEBHOOK_DELIVERY);
+ } else {
+ pollGate.recordBusy(WorkQueue.WEBHOOK_DELIVERY);
+ }
+ for (String deliveryId : deliveryIds) {
+ dispatchOne(deliveryId);
+ }
+ }
+
+ private void dispatchOne(String deliveryId) {
+ try {
+ log.info("webhook delivery 처리 위임: deliveryId={} workerId={}", deliveryId, workerId);
+ webhookExecutor.execute(() -> processSafely(deliveryId));
+ } catch (RuntimeException exception) { // TaskRejectedException(실행기 포화) 포함
+ boolean released = webhookDeliveryRepository.releaseClaim(
+ deliveryId, workerId, dispatchRejectBackoffMs);
+ log.warn("webhook delivery 위임 실패 — 재대기열로 반환합니다. deliveryId={} workerId={} released={} 원인={}",
+ deliveryId, workerId, released, exception.toString());
+ }
+ }
+
+ /**
+ * executor 스레드에서 도는 실제 처리. {@code processDelivery} 는 핸들러 예외를 스스로 잡아
+ * RETRY_WAIT 로 적으므로 여기까지 올라오는 것은 그 바깥의 사고(배달 행이 사라졌다든지)뿐이다.
+ * 그것을 삼키지 않고 남긴다 — executor 스레드에서 던지면 아무 데도 안 남고, 그 행은 리스
+ * 만료까지 PROCESSING 으로 보인다.
+ */
+ private void processSafely(String deliveryId) {
+ try {
webhookService.processDelivery(deliveryId);
+ } catch (RuntimeException exception) {
+ log.error("webhook delivery 처리 중 예기치 못한 오류 — 리스 만료 후 회수됩니다. deliveryId={}",
+ deliveryId, exception);
}
}
}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index e7cff4b5..85f448cd 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -237,7 +237,12 @@ qeploy:
# ADR-Y2 (#55): StuckApprovalSweeper's poll interval — defense-in-depth recovery for the D1
# write-skew stuck state (structurally prevented by ADR-Y1's lock hierarchy; this is the
# safety net for anything that slips through anyway).
- sweep-interval-ms: ${QEPLOY_AGENT_APPROVAL_SWEEP_INTERVAL_MS:60000}
+ #
+ # #340 5-7: 60초 → 5분. 이 스윕은 자기 자신의 자바독이 "정상이면 항상 0건" 이라고 적은
+ # 회귀망이다. 회귀가 실제로 생겼을 때 5분 안에 잡히면 충분하고(사용자는 이미 승인 버튼을
+ # 누른 뒤 기다리는 중이 아니다 — 그 경로는 ADR-Y1 이 구조적으로 막았다), 그 사이 매분
+ # 도는 것은 정상 상태에서 순수한 낭비다.
+ sweep-interval-ms: ${QEPLOY_AGENT_APPROVAL_SWEEP_INTERVAL_MS:300000}
# AbandonedApprovalSweeper: 아무도 결정하지 않은 승인 대기 태스크를 닫기까지 기다리는 시간.
# 넉넉해야 한다 — 승인 카드를 띄워둔 채 하루 자리를 비우는 것은 정상이고, 그걸 취소하면
# 스윕이 오히려 기능을 망가뜨린다. 0 이하로 두면 스윕이 꺼진다.
@@ -252,6 +257,10 @@ qeploy:
worker:
poll-interval-ms: ${QEPLOY_DEPLOYMENT_WORKER_POLL_INTERVAL_MS:1000}
heartbeat-interval-ms: ${QEPLOY_DEPLOYMENT_WORKER_HEARTBEAT_INTERVAL_MS:30000}
+ # #340 5-2: deploymentExecutor 가 포화라 claim 한 이력을 넘기지 못했을 때, PENDING 으로
+ # 되돌리며 next_run_at 에 얹는 backoff. 포화 상태에서 claim<->release 가 매 폴링 반복되는
+ # 것을 막는다(AgentRunWorker 의 같은 이름 설정과 같은 역할).
+ dispatch-reject-backoff-ms: ${QEPLOY_DEPLOYMENT_WORKER_DISPATCH_REJECT_BACKOFF_MS:5000}
recovery:
# 결과 웹훅을 놓쳐 IN_PROGRESS 에 멈춘 배포를 GitHub 에 직접 물어 회수한다.
poll-interval-ms: ${QEPLOY_DEPLOYMENT_RECOVERY_POLL_INTERVAL_MS:60000}
@@ -292,6 +301,9 @@ qeploy:
webhook:
worker:
poll-interval-ms: ${QEPLOY_WEBHOOK_WORKER_POLL_INTERVAL_MS:1000}
+ # #340 5-3: webhookExecutor 가 포화라 claim 한 배달을 넘기지 못했을 때, PENDING 으로
+ # 되돌리며 next_attempt_at 에 얹는 backoff.
+ dispatch-reject-backoff-ms: ${QEPLOY_WEBHOOK_WORKER_DISPATCH_REJECT_BACKOFF_MS:5000}
# WebhookDeliveryRetentionScheduler (#338): 이 테이블에는 삭제 로직이 없어서 payload
# LONGBLOB 이 이벤트마다 영구히 쌓이고 있었다. 멱등·재시도를 위한 기록이라 최종 상태에
# 도달하고 며칠 지나면 아무도 읽지 않는다. 터미널 상태만 지운다 — 처리 대기·재시도 대기·
@@ -310,6 +322,22 @@ qeploy:
# 사라져 사용자가 인증이 왜 깨졌는지 알 수 없다 — "만료됨"을 얼마간 보여준 뒤 지운다.
expired-grace-days: ${QEPLOY_API_TOKEN_EXPIRED_GRACE_DAYS:30}
+ # #340 5-1 — 1초 폴링 워커(Agent·배포·클라우드 연결·웹훅)의 적응형 백오프.
+ #
+ # 워커의 @Scheduled 간격(각 도메인의 worker.poll-interval-ms)은 그대로 1초이고, 여기 값은
+ # "그 틱에 DB 를 칠지"를 정한다. 일이 있으면 base-delay-ms, 없으면 두 배씩 늘려 max-delay-ms
+ # 까지 물러난다. 유휴 상태 DB 비용의 대부분이 폴링당 트랜잭션 의례였으므로, 폴링 횟수를 줄이면
+ # 의례까지 같은 비율로 줄어든다.
+ #
+ # max-delay-ms 를 늘려도 사용자 대기가 늘지는 않는다 — enqueue 가 커밋되면 그 큐의 백오프가
+ # 즉시 풀린다(WorkerPollGate). 이 값이 실제로 정하는 것은 "다른 인스턴스가 넣은 일을 최대 얼마나
+ # 늦게 보는가"다. 깨우기 신호는 JVM 로컬이라 인스턴스를 넘지 못하므로, 이 폴백 폴링이 다중
+ # 인스턴스 안전성의 마지막 보루다. 0 으로 두면 안 된다.
+ worker:
+ poll:
+ base-delay-ms: ${QEPLOY_WORKER_POLL_BASE_DELAY_MS:1000}
+ max-delay-ms: ${QEPLOY_WORKER_POLL_MAX_DELAY_MS:30000}
+
# Cloudflare DNS - managed subdomain automation
cloudflare:
# 값은 서버 환경변수로만 넣는다 — 이 파일에 적으면 저장소로 새어 나간다.
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/store/TaskStoreTest.java b/src/test/java/com/example/dvely/agent/infrastructure/store/TaskStoreTest.java
index a6691389..7315533f 100644
--- a/src/test/java/com/example/dvely/agent/infrastructure/store/TaskStoreTest.java
+++ b/src/test/java/com/example/dvely/agent/infrastructure/store/TaskStoreTest.java
@@ -70,7 +70,7 @@ void setUp() {
// repository query being exercised against a real DB elsewhere).
when(runRepository.findByTaskIdForUpdate(any(String.class)))
.thenAnswer(invocation -> Optional.ofNullable(runs.get(invocation.getArgument(0))));
- taskStore = new TaskStore(runRepository, eventRepository, new ObjectMapper());
+ taskStore = new TaskStore(runRepository, eventRepository, new ObjectMapper(), event -> { });
}
@Test
@@ -279,7 +279,7 @@ void persistedPlanCanBeReadByNewStoreInstance() {
);
taskStore.savePlan("task-1", plan);
- TaskStore restartedStore = new TaskStore(runRepository, eventRepository, new ObjectMapper());
+ TaskStore restartedStore = new TaskStore(runRepository, eventRepository, new ObjectMapper(), event -> { });
assertThat(restartedStore.getPlan("task-1")).isEqualTo(plan);
}
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorkerPollingTest.java b/src/test/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorkerPollingTest.java
new file mode 100644
index 00000000..e2b14d55
--- /dev/null
+++ b/src/test/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorkerPollingTest.java
@@ -0,0 +1,174 @@
+package com.example.dvely.agent.infrastructure.worker;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.example.dvely.agent.application.orchestrator.AgentPlanExecutor;
+import com.example.dvely.agent.application.service.AgentMessageService;
+import com.example.dvely.agent.infrastructure.store.TaskStore;
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkQueuedEvent;
+import com.example.dvely.common.worker.WorkerPollGate;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.jupiter.api.Test;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+/**
+ * #340 5-1 — 워커가 게이트를 실제로 지키는가. {@code WorkerPollGateTest} 가 백오프 계산 자체를
+ * 못박는다면, 이쪽은 "닫힌 게이트에서는 DB 를 한 번도 치지 않는다"와 "깨우면 바로 친다"를 본다.
+ */
+class AgentRunWorkerPollingTest {
+
+ private static final long BASE_MS = 1_000L;
+ private static final long MAX_MS = 30_000L;
+ private static final long BACKOFF_MS = 5_000L;
+
+ private final AtomicLong nanos = new AtomicLong();
+ private final WorkerPollGate gate = new WorkerPollGate(BASE_MS, MAX_MS, nanos::get);
+ private final TaskStore taskStore = mock(TaskStore.class);
+
+ private void advance(long millis) {
+ nanos.addAndGet(TimeUnit.MILLISECONDS.toNanos(millis));
+ }
+
+ private AgentRunWorker worker() {
+ return new AgentRunWorker(
+ taskStore,
+ mock(AgentPlanExecutor.class),
+ mock(AgentMessageService.class),
+ new AgentExecutionRegistry(),
+ freeExecutor(),
+ gate,
+ BACKOFF_MS
+ );
+ }
+
+ private void stubEmptyPoll() {
+ when(taskStore.recoverAndClaim(anyString(), anyInt()))
+ .thenReturn(new TaskStore.PollBatch(List.of(), List.of()));
+ }
+
+ /**
+ * 이 단위의 목적 그 자체 — 유휴가 이어지면 틱이 와도 쿼리가 나가지 않는다. 유휴 DB
+ * 비용의 대부분이 폴링당 트랜잭션 의례였으므로, 폴링 횟수가 곧 비용이다.
+ */
+ @Test
+ void idleTicksStopTouchingTheDatabaseEntirely() {
+ stubEmptyPoll();
+ AgentRunWorker worker = worker();
+
+ worker.dispatchQueuedRuns(); // 1회차: 실제 폴링, 빈손 → 2초 백오프
+ verify(taskStore, times(1)).recoverAndClaim(anyString(), anyInt());
+
+ // 그 뒤 1초 틱이 아무리 와도 백오프가 끝나기 전에는 쿼리가 없다.
+ advance(1_000);
+ worker.dispatchQueuedRuns();
+ verify(taskStore, times(1)).recoverAndClaim(anyString(), anyInt());
+
+ advance(1_000);
+ worker.dispatchQueuedRuns(); // 2초 경과 — 여기서만 다시 친다
+ verify(taskStore, times(2)).recoverAndClaim(anyString(), anyInt());
+ }
+
+ /**
+ * 첫 claim 지연. 백오프가 상한(30초)까지 늘어난 뒤에도, 태스크가 큐에 들어가면 바로 다음
+ * 틱에 claim 이 나간다 — 사용자가 백오프만큼 기다리는 일은 없다.
+ */
+ @Test
+ void aTaskQueuedWhileFullyBackedOffIsClaimedOnTheVeryNextTick() {
+ stubEmptyPoll();
+ AgentRunWorker worker = worker();
+
+ // 상한까지 물러나게 만든다.
+ for (int i = 0; i < 8; i++) {
+ worker.dispatchQueuedRuns();
+ advance(MAX_MS);
+ }
+ worker.dispatchQueuedRuns();
+ int pollsWhileIdle = org.mockito.Mockito.mockingDetails(taskStore).getInvocations().size();
+ worker.dispatchQueuedRuns(); // 백오프 중 — 쿼리 없음
+ assertThat(org.mockito.Mockito.mockingDetails(taskStore).getInvocations().size())
+ .as("백오프 중인 틱은 DB 를 치지 않는다")
+ .isEqualTo(pollsWhileIdle);
+
+ // 사용자가 메시지를 보냈다 = enqueue 가 커밋됐다.
+ gate.onWorkQueued(new WorkQueuedEvent(WorkQueue.AGENT_RUN));
+
+ worker.dispatchQueuedRuns();
+
+ assertThat(org.mockito.Mockito.mockingDetails(taskStore).getInvocations().size())
+ .as("깨우기 직후 첫 틱에서 claim 이 나가야 한다 — 지연은 최대 폴링 틱 1회(1초)")
+ .isGreaterThan(pollsWhileIdle);
+ }
+
+ /**
+ * 실행기가 포화라 claim 을 생략한 폴링은 "일이 없다"가 아니다. 자리가 나는 것을 알려줄 신호는
+ * 없으므로, 여기서 물러나면 큐에 쌓인 태스크가 백오프 상한만큼 늦게 출발한다.
+ */
+ @Test
+ void aPollSkippedForExecutorSaturationDoesNotBackOff() throws Exception {
+ stubEmptyPoll();
+ ThreadPoolTaskExecutor saturated = new ThreadPoolTaskExecutor();
+ saturated.setCorePoolSize(1);
+ saturated.setMaxPoolSize(1);
+ saturated.setQueueCapacity(0);
+ saturated.initialize();
+ java.util.concurrent.CountDownLatch started = new java.util.concurrent.CountDownLatch(1);
+ java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1);
+ saturated.execute(() -> {
+ started.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ try {
+ started.await(2, TimeUnit.SECONDS);
+ AgentRunWorker worker = new AgentRunWorker(
+ taskStore, mock(AgentPlanExecutor.class), mock(AgentMessageService.class),
+ new AgentExecutionRegistry(), saturated, gate, BACKOFF_MS);
+
+ worker.dispatchQueuedRuns();
+ worker.dispatchQueuedRuns();
+
+ // claim 은 한 번도 안 하지만(claimLimit=0), 회수는 매 틱 돈다 — 물러나지 않았다는 뜻이다.
+ verify(taskStore, times(2)).recoverAndClaim(anyString(), eq(0));
+ } finally {
+ release.countDown();
+ saturated.shutdown();
+ }
+ }
+
+ @Test
+ void aClosedGateSkipsTheStoreCompletely() {
+ stubEmptyPoll();
+ AgentRunWorker worker = worker();
+ worker.dispatchQueuedRuns();
+ org.mockito.Mockito.clearInvocations(taskStore);
+
+ worker.dispatchQueuedRuns(); // 아직 백오프 중
+
+ verifyNoInteractions(taskStore);
+ verify(taskStore, never()).recoverAndClaim(anyString(), anyInt());
+ }
+
+ private ThreadPoolTaskExecutor freeExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(5);
+ executor.setQueueCapacity(10);
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorkerTest.java b/src/test/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorkerTest.java
index 1ffaa806..41dc7655 100644
--- a/src/test/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorkerTest.java
+++ b/src/test/java/com/example/dvely/agent/infrastructure/worker/AgentRunWorkerTest.java
@@ -20,9 +20,12 @@
import com.example.dvely.agent.application.service.AgentMessageService;
import com.example.dvely.agent.domain.value.AiProvider;
import com.example.dvely.agent.infrastructure.store.TaskStore;
+import com.example.dvely.common.worker.WorkerPollGate;
import java.time.Instant;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.core.task.TaskRejectedException;
@@ -38,16 +41,17 @@ void recoversLeasesAndDispatchesClaimedTask() {
AgentMessageService messageService = mock(AgentMessageService.class);
AgentPlanExecutor executor = mock(AgentPlanExecutor.class);
AgentExecutionRegistry registry = new AgentExecutionRegistry();
- AgentRunWorker worker = new AgentRunWorker(taskStore, executor, messageService, registry, freeExecutor(), BACKOFF_MS);
+ AgentRunWorker worker = newWorker(taskStore, executor, messageService, registry, freeExecutor(), BACKOFF_MS);
AgentPlan plan = new AgentPlan(List.of(), "reason", AiProvider.OPENAI, 11L);
AgentTask task = agentTask();
- when(taskStore.claimRunnableTasks(anyString(), eq(2))).thenReturn(List.of("task-1"));
+ stubPoll(taskStore, List.of(), List.of("task-1"));
when(taskStore.get("task-1")).thenReturn(task);
when(taskStore.getPlan("task-1")).thenReturn(plan);
worker.dispatchQueuedRuns();
- verify(taskStore).recoverExpiredLeases();
+ // #340 5-1: 회수와 claim 은 이제 한 트랜잭션(recoverAndClaim)이다.
+ verify(taskStore).recoverAndClaim(anyString(), eq(2));
verify(executor).execute(plan, "task-1", 1L);
}
@@ -61,11 +65,10 @@ void leaseExhaustionTellsTheUserInsteadOfLeavingTheScreenSilent() {
TaskStore taskStore = mock(TaskStore.class);
AgentMessageService messageService = mock(AgentMessageService.class);
AgentExecutionRegistry registry = new AgentExecutionRegistry();
- AgentRunWorker worker = new AgentRunWorker(
+ AgentRunWorker worker = newWorker(
taskStore, mock(AgentPlanExecutor.class), messageService, registry, freeExecutor(), BACKOFF_MS);
- when(taskStore.recoverExpiredLeases()).thenReturn(List.of("task-1"));
+ stubPoll(taskStore, List.of("task-1"), List.of());
when(taskStore.get("task-1")).thenReturn(agentTask());
- when(taskStore.claimRunnableTasks(anyString(), eq(2))).thenReturn(List.of());
worker.dispatchQueuedRuns();
@@ -79,10 +82,9 @@ void leaseRecoveryWithoutExhaustionSaysNothing() {
TaskStore taskStore = mock(TaskStore.class);
AgentMessageService messageService = mock(AgentMessageService.class);
AgentExecutionRegistry registry = new AgentExecutionRegistry();
- AgentRunWorker worker = new AgentRunWorker(
+ AgentRunWorker worker = newWorker(
taskStore, mock(AgentPlanExecutor.class), messageService, registry, freeExecutor(), BACKOFF_MS);
- when(taskStore.recoverExpiredLeases()).thenReturn(List.of());
- when(taskStore.claimRunnableTasks(anyString(), eq(2))).thenReturn(List.of());
+ stubPoll(taskStore, List.of(), List.of());
worker.dispatchQueuedRuns();
@@ -97,10 +99,10 @@ void registersTaskInExecutionRegistryBeforeSubmittingToTheExecutor() {
AgentMessageService messageService = mock(AgentMessageService.class);
AgentPlanExecutor executor = mock(AgentPlanExecutor.class);
AgentExecutionRegistry registry = mock(AgentExecutionRegistry.class);
- AgentRunWorker worker = new AgentRunWorker(taskStore, executor, messageService, registry, freeExecutor(), BACKOFF_MS);
+ AgentRunWorker worker = newWorker(taskStore, executor, messageService, registry, freeExecutor(), BACKOFF_MS);
AgentPlan plan = new AgentPlan(List.of(), "reason", AiProvider.OPENAI, 11L);
AgentTask task = agentTask();
- when(taskStore.claimRunnableTasks(anyString(), eq(2))).thenReturn(List.of("task-1"));
+ stubPoll(taskStore, List.of(), List.of("task-1"));
when(taskStore.get("task-1")).thenReturn(task);
when(taskStore.getPlan("task-1")).thenReturn(plan);
@@ -122,11 +124,11 @@ void executorRejectionReleasesClaimUnregistersAndContinuesDispatchingTheRestOfTh
AgentMessageService messageService = mock(AgentMessageService.class);
AgentPlanExecutor executor = mock(AgentPlanExecutor.class);
AgentExecutionRegistry registry = mock(AgentExecutionRegistry.class);
- AgentRunWorker worker = new AgentRunWorker(taskStore, executor, messageService, registry, freeExecutor(), BACKOFF_MS);
+ AgentRunWorker worker = newWorker(taskStore, executor, messageService, registry, freeExecutor(), BACKOFF_MS);
AgentPlan plan = new AgentPlan(List.of(), "reason", AiProvider.OPENAI, 11L);
AgentTask rejectedTask = agentTask("task-1");
AgentTask okTask = agentTask("task-2");
- when(taskStore.claimRunnableTasks(anyString(), eq(2))).thenReturn(List.of("task-1", "task-2"));
+ stubPoll(taskStore, List.of(), List.of("task-1", "task-2"));
when(taskStore.get("task-1")).thenReturn(rejectedTask);
when(taskStore.get("task-2")).thenReturn(okTask);
when(taskStore.getPlan("task-1")).thenReturn(plan);
@@ -152,7 +154,7 @@ void heartbeatSkipsTheRenewalQueryWhenTheRegistryIsEmpty() {
TaskStore taskStore = mock(TaskStore.class);
AgentMessageService messageService = mock(AgentMessageService.class);
AgentExecutionRegistry registry = new AgentExecutionRegistry();
- AgentRunWorker worker = new AgentRunWorker(taskStore, mock(AgentPlanExecutor.class), messageService, registry,
+ AgentRunWorker worker = newWorker(taskStore, mock(AgentPlanExecutor.class), messageService, registry,
freeExecutor(), BACKOFF_MS);
worker.renewLeases();
@@ -167,7 +169,7 @@ void heartbeatRenewsExactlyTheRegisteredTaskIds() {
AgentExecutionRegistry registry = new AgentExecutionRegistry();
registry.register("task-1");
registry.register("task-2");
- AgentRunWorker worker = new AgentRunWorker(taskStore, mock(AgentPlanExecutor.class), messageService, registry,
+ AgentRunWorker worker = newWorker(taskStore, mock(AgentPlanExecutor.class), messageService, registry,
freeExecutor(), BACKOFF_MS);
worker.renewLeases();
@@ -206,19 +208,44 @@ void skipsClaimEntirelyWhenTheExecutorHasNoFreeCapacity() throws InterruptedExce
});
try {
started.await(2, java.util.concurrent.TimeUnit.SECONDS);
- AgentRunWorker worker = new AgentRunWorker(taskStore, mock(AgentPlanExecutor.class), messageService,
+ stubPoll(taskStore, List.of(), List.of());
+ AgentRunWorker worker = newWorker(taskStore, mock(AgentPlanExecutor.class), messageService,
new AgentExecutionRegistry(), saturated, BACKOFF_MS);
worker.dispatchQueuedRuns();
- verify(taskStore).recoverExpiredLeases();
- verify(taskStore, never()).claimRunnableTasks(anyString(), org.mockito.ArgumentMatchers.anyInt());
+ // #340 5-1: 포화여도 회수는 돌아야 한다(좀비 리스가 그만큼 오래 남으므로). 그래서
+ // 폴링을 건너뛰는 대신 claimLimit=0 으로 회수만 시킨다.
+ verify(taskStore).recoverAndClaim(anyString(), eq(0));
} finally {
release.countDown();
saturated.shutdown();
}
}
+ /**
+ * 게이트를 항상 열어 두는 워커. 이 파일의 테스트들은 백오프가 아니라 dispatch 동작을 보므로,
+ * 폴링 호출마다 시계를 넉넉히 흘려 게이트가 판단에 끼어들지 않게 한다. 백오프 자체는
+ * {@code WorkerPollGateTest} 와 {@code AgentRunWorkerBackoffTest} 가 따로 못박는다.
+ */
+ private static AgentRunWorker newWorker(TaskStore taskStore,
+ AgentPlanExecutor planExecutor,
+ AgentMessageService messageService,
+ AgentExecutionRegistry registry,
+ ThreadPoolTaskExecutor agentExecutor,
+ long backoffMs) {
+ AtomicLong nanos = new AtomicLong();
+ WorkerPollGate openGate = new WorkerPollGate(
+ 1000L, 30_000L, () -> nanos.addAndGet(TimeUnit.MINUTES.toNanos(1)));
+ return new AgentRunWorker(
+ taskStore, planExecutor, messageService, registry, agentExecutor, openGate, backoffMs);
+ }
+
+ private static void stubPoll(TaskStore taskStore, List leaseExhausted, List claimed) {
+ when(taskStore.recoverAndClaim(anyString(), org.mockito.ArgumentMatchers.anyInt()))
+ .thenReturn(new TaskStore.PollBatch(leaseExhausted, claimed));
+ }
+
private String workerIdOf(AgentRunWorker worker) {
return java.lang.management.ManagementFactory.getRuntimeMXBean().getName();
}
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/worker/DockerGarbageSweeperTest.java b/src/test/java/com/example/dvely/agent/infrastructure/worker/DockerGarbageSweeperTest.java
index d54b069e..391da0d8 100644
--- a/src/test/java/com/example/dvely/agent/infrastructure/worker/DockerGarbageSweeperTest.java
+++ b/src/test/java/com/example/dvely/agent/infrastructure/worker/DockerGarbageSweeperTest.java
@@ -17,7 +17,11 @@ class DockerGarbageSweeperTest {
private final DockerContainerService dockerService = mock(DockerContainerService.class);
private DockerGarbageSweeper sweeper(boolean enabled, long keepHours) {
- DockerGarbageSweeper sweeper = new DockerGarbageSweeper(dockerService);
+ return sweeper(enabled, keepHours, Runnable::run);
+ }
+
+ private DockerGarbageSweeper sweeper(boolean enabled, long keepHours, java.util.concurrent.Executor executor) {
+ DockerGarbageSweeper sweeper = new DockerGarbageSweeper(dockerService, executor);
ReflectionTestUtils.setField(sweeper, "enabled", enabled);
ReflectionTestUtils.setField(sweeper, "buildCacheKeepHours", keepHours);
return sweeper;
@@ -51,4 +55,21 @@ void survivesWhenDockerIsUnreachable() {
Assertions.assertThatCode(() -> sweeper(true, 48).sweep()).doesNotThrowAnyException();
}
+
+ /**
+ * #340 5-10 — prune 은 스케줄러 스레드에서 돌지 않는다. prune 3회가 도커 데몬을 잠시
+ * 붙잡는 동안 스케줄러를 점유하면 같은 풀을 쓰는 다른 잡이 그만큼 밀린다.
+ */
+ @Test
+ void pruningRunsOnTheMaintenanceExecutorNotTheSchedulerThread() {
+ java.util.List submitted = new java.util.ArrayList<>();
+
+ sweeper(true, 48, submitted::add).sweep();
+
+ Assertions.assertThat(submitted).hasSize(1);
+ verify(dockerService, never()).pruneGarbage(any());
+
+ submitted.get(0).run();
+ verify(dockerService).pruneGarbage(Duration.ofHours(48));
+ }
}
diff --git a/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java b/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java
index ff6ec0ed..5d41807c 100644
--- a/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java
+++ b/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java
@@ -198,32 +198,20 @@ void permanentlyDeleteConversationRejectsActiveConversation() {
verify(conversationRepository, never()).deleteById(any());
}
+ /**
+ * #340 5-9: 만료된 휴지통 대화를 벌크 DELETE 한 문장으로 지운다. 예전에는 엔티티를 전부
+ * 로드한 뒤 {@code deleteById} 를 N 번 불렀다 — 지우려고 읽고, 지우려고 또 왕복했다.
+ * 삭제 조건이 곧 SELECT 조건이었으므로 읽을 이유가 없다.
+ */
@Test
- void purgeExpiredConversationsDeletesRepositoryMatches() {
- Conversation first = new Conversation(
- 11L,
- 2L,
- 7L,
- true,
- LocalDateTime.now().minusDays(8),
- LocalDateTime.now().minusDays(10),
- LocalDateTime.now().minusDays(8)
- );
- Conversation second = new Conversation(
- 12L,
- 2L,
- 7L,
- true,
- LocalDateTime.now().minusDays(9),
- LocalDateTime.now().minusDays(10),
- LocalDateTime.now().minusDays(9)
- );
- when(conversationRepository.findAllByDeletedTrueAndDeletedAtLessThanEqual(any()))
- .thenReturn(List.of(first, second));
+ void purgeExpiredConversationsDeletesInOneBulkStatement() {
+ when(conversationRepository.deleteExpiredTrash(any())).thenReturn(2);
assertThat(chatCommandService.purgeExpiredConversations()).isEqualTo(2);
- verify(conversationRepository).deleteById(11L);
- verify(conversationRepository).deleteById(12L);
+
+ verify(conversationRepository).deleteExpiredTrash(any());
+ verify(conversationRepository, never()).findAllByDeletedTrueAndDeletedAtLessThanEqual(any());
+ verify(conversationRepository, never()).deleteById(any());
}
private Project project(Long projectId, Long ownerUserId, String sourceRepository, boolean deleted) {
diff --git a/src/test/java/com/example/dvely/cloudconnection/infrastructure/worker/CloudConnectionVerificationWorkerTest.java b/src/test/java/com/example/dvely/cloudconnection/infrastructure/worker/CloudConnectionVerificationWorkerTest.java
new file mode 100644
index 00000000..058e97c5
--- /dev/null
+++ b/src/test/java/com/example/dvely/cloudconnection/infrastructure/worker/CloudConnectionVerificationWorkerTest.java
@@ -0,0 +1,65 @@
+package com.example.dvely.cloudconnection.infrastructure.worker;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.example.dvely.cloudconnection.application.service.CloudConnectionVerificationService;
+import com.example.dvely.cloudconnection.domain.repository.CloudConnectionVerificationJobRepository;
+import com.example.dvely.common.worker.WorkerPollGate;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.task.TaskRejectedException;
+
+class CloudConnectionVerificationWorkerTest {
+
+ /** 백오프가 이 파일의 dispatch 검증에 끼어들지 않도록 게이트를 항상 열어 둔다. */
+ private static WorkerPollGate openGate() {
+ AtomicLong nanos = new AtomicLong();
+ return new WorkerPollGate(1000L, 30_000L, () -> nanos.addAndGet(TimeUnit.MINUTES.toNanos(1)));
+ }
+
+ @Test
+ void dispatchPendingJobs_recoversClaimsAndDelegatesJobs() {
+ CloudConnectionVerificationJobRepository repository =
+ mock(CloudConnectionVerificationJobRepository.class);
+ CloudConnectionVerificationService service = mock(CloudConnectionVerificationService.class);
+ CloudConnectionVerificationWorker worker = new CloudConnectionVerificationWorker(repository, service, openGate());
+ when(repository.recoverAndClaimPending(anyString(), eq(2))).thenReturn(List.of("job-1", "job-2"));
+
+ worker.dispatchPendingJobs();
+
+ // #340 5-1: 회수와 claim 은 이제 한 트랜잭션(recoverAndClaimPending)이다.
+ verify(repository).recoverAndClaimPending(anyString(), eq(2));
+ verify(service).executeQueued("job-1");
+ verify(service).executeQueued("job-2");
+ }
+
+ /**
+ * #340 5-2 재현: {@code cloudConnectionExecutor} 포화로 첫 job 의 위임이 거부되면, 예전에는 그
+ * 예외가 루프를 끊어 같은 배치의 job-2 가 RUNNING 인 채 리스 만료(2분)까지 방치됐다. 그 동안
+ * 사용자에게는 "권한을 확인하고 있습니다"만 떠 있고 실제로 확인하는 것은 아무것도 없었다.
+ */
+ @Test
+ void executorRejectionDoesNotStrandTheRestOfTheClaimedBatch() {
+ CloudConnectionVerificationJobRepository repository =
+ mock(CloudConnectionVerificationJobRepository.class);
+ CloudConnectionVerificationService service = mock(CloudConnectionVerificationService.class);
+ CloudConnectionVerificationWorker worker = new CloudConnectionVerificationWorker(repository, service, openGate());
+ when(repository.recoverAndClaimPending(anyString(), eq(2))).thenReturn(List.of("job-1", "job-2"));
+ doThrow(new TaskRejectedException("cloudConnectionExecutor 포화"))
+ .when(service).executeQueued("job-1");
+
+ worker.dispatchPendingJobs();
+
+ verify(service).executeQueued("job-2");
+ verify(repository).releaseClaim(eq("job-1"), anyString());
+ verify(repository, never()).releaseClaim(eq("job-2"), anyString());
+ }
+}
diff --git a/src/test/java/com/example/dvely/common/worker/WorkerPollGateTest.java b/src/test/java/com/example/dvely/common/worker/WorkerPollGateTest.java
new file mode 100644
index 00000000..f6424d87
--- /dev/null
+++ b/src/test/java/com/example/dvely/common/worker/WorkerPollGateTest.java
@@ -0,0 +1,125 @@
+package com.example.dvely.common.worker;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.jupiter.api.Test;
+
+/**
+ * #340 5-1 — 적응형 백오프의 계약.
+ *
+ * 시계를 주입해 벽시계에 의존하지 않는다. 백오프는 "몇 초 뒤"가 본질이라 실제로 재우면
+ * 테스트가 느려지고 흔들린다.
+ */
+class WorkerPollGateTest {
+
+ private static final long BASE_MS = 1_000L;
+ private static final long MAX_MS = 30_000L;
+
+ private final AtomicLong nanos = new AtomicLong();
+ private final WorkerPollGate gate = new WorkerPollGate(BASE_MS, MAX_MS, nanos::get);
+
+ private void advance(long millis) {
+ nanos.addAndGet(TimeUnit.MILLISECONDS.toNanos(millis));
+ }
+
+ @Test
+ void firstTickAfterStartupAlwaysPolls() {
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN)).isTrue();
+ }
+
+ @Test
+ void idlePollsBackOffByDoublingUpToTheCap() {
+ // 유휴가 이어지면 간격이 1 → 2 → 4 → 8 → 16 → 30(상한) 초로 늘어난다.
+ long[] expectedDelaysMs = {2_000L, 4_000L, 8_000L, 16_000L, 30_000L, 30_000L};
+ for (long expected : expectedDelaysMs) {
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN)).isTrue();
+ gate.recordIdle(WorkQueue.AGENT_RUN);
+
+ // 기대 간격 직전까지는 닫혀 있고, 지나면 열린다.
+ advance(expected - 1);
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN))
+ .as("%dms 백오프가 끝나기 전에는 DB 를 치지 않는다", expected)
+ .isFalse();
+ advance(1);
+ }
+ }
+
+ @Test
+ void findingWorkResetsTheIntervalToTheMinimum() {
+ gate.shouldPoll(WorkQueue.AGENT_RUN);
+ gate.recordIdle(WorkQueue.AGENT_RUN);
+ gate.shouldPoll(WorkQueue.AGENT_RUN);
+ advance(2_000);
+ gate.shouldPoll(WorkQueue.AGENT_RUN);
+ gate.recordIdle(WorkQueue.AGENT_RUN); // 여기서 간격은 4초
+
+ advance(4_000);
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN)).isTrue();
+ gate.recordBusy(WorkQueue.AGENT_RUN);
+
+ // 일을 찾았으니 즉시 다시 열려 있어야 한다 — 큐에 더 남아 있을 수 있다.
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN)).isTrue();
+ gate.recordIdle(WorkQueue.AGENT_RUN);
+ advance(1_999);
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN))
+ .as("간격이 최소(1초)로 초기화됐으므로 다음 백오프는 4초가 아니라 2초다")
+ .isFalse();
+ advance(1);
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN)).isTrue();
+ }
+
+ /**
+ * 이 단위의 핵심 계약 — 백오프가 상한까지 늘어나 있어도, 일이 들어오면 즉시 열린다.
+ * 이것이 없으면 사용자가 메시지를 보낸 뒤 최대 30초를 기다리게 된다.
+ */
+ @Test
+ void wakingUpOpensTheGateImmediatelyNoMatterHowFarItHadBackedOff() {
+ for (int i = 0; i < 10; i++) {
+ gate.shouldPoll(WorkQueue.AGENT_RUN);
+ gate.recordIdle(WorkQueue.AGENT_RUN);
+ advance(MAX_MS);
+ }
+ gate.shouldPoll(WorkQueue.AGENT_RUN);
+ gate.recordIdle(WorkQueue.AGENT_RUN); // 상한(30초) 백오프에 들어간 상태
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN)).isFalse();
+
+ gate.onWorkQueued(new WorkQueuedEvent(WorkQueue.AGENT_RUN));
+
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN))
+ .as("enqueue 커밋 뒤에는 백오프 값과 무관하게 다음 틱에 폴링해야 한다")
+ .isTrue();
+ }
+
+ @Test
+ void wakingOneQueueDoesNotDisturbAnother() {
+ gate.shouldPoll(WorkQueue.AGENT_RUN);
+ gate.recordIdle(WorkQueue.AGENT_RUN);
+ gate.shouldPoll(WorkQueue.WEBHOOK_DELIVERY);
+ gate.recordIdle(WorkQueue.WEBHOOK_DELIVERY);
+
+ gate.onWorkQueued(new WorkQueuedEvent(WorkQueue.AGENT_RUN));
+
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN)).isTrue();
+ assertThat(gate.shouldPoll(WorkQueue.WEBHOOK_DELIVERY))
+ .as("배포 큐에 일이 들어왔다고 웹훅 워커까지 깨울 이유는 없다")
+ .isFalse();
+ }
+
+ /**
+ * 폴링이 도는 도중에 깨우기가 오는 경우. 그 폴링은 방금 커밋된 행을 못 봤을 수 있으므로,
+ * 빈손으로 끝났다고 백오프를 늘리면 이미 들어온 일이 최대 상한만큼 늦어진다.
+ */
+ @Test
+ void aWakeupThatArrivesMidPollIsNotSwallowedByThatPollsBackoff() {
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN)).isTrue(); // 폴링 시작
+
+ gate.onWorkQueued(new WorkQueuedEvent(WorkQueue.AGENT_RUN)); // 폴링 도중 enqueue 커밋
+ gate.recordIdle(WorkQueue.AGENT_RUN); // 이 폴링은 빈손이었다
+
+ assertThat(gate.shouldPoll(WorkQueue.AGENT_RUN))
+ .as("폴링 중에 온 깨우기는 그 폴링의 백오프에 묻히면 안 된다")
+ .isTrue();
+ }
+}
diff --git a/src/test/java/com/example/dvely/deployment/application/command/DeploymentCommandServiceTest.java b/src/test/java/com/example/dvely/deployment/application/command/DeploymentCommandServiceTest.java
index 409e643e..fec54112 100644
--- a/src/test/java/com/example/dvely/deployment/application/command/DeploymentCommandServiceTest.java
+++ b/src/test/java/com/example/dvely/deployment/application/command/DeploymentCommandServiceTest.java
@@ -79,6 +79,7 @@ void setUp() {
githubActionsPort,
githubRepoPort,
deploymentHistoryRepository,
+ new com.example.dvely.deployment.infrastructure.worker.DeploymentExecutionRegistry(),
policyRepository,
resultApprovalService,
auditRecorder,
diff --git a/src/test/java/com/example/dvely/deployment/infrastructure/worker/DeploymentRunWorkerTest.java b/src/test/java/com/example/dvely/deployment/infrastructure/worker/DeploymentRunWorkerTest.java
index 7e8b5f1f..c2764b85 100644
--- a/src/test/java/com/example/dvely/deployment/infrastructure/worker/DeploymentRunWorkerTest.java
+++ b/src/test/java/com/example/dvely/deployment/infrastructure/worker/DeploymentRunWorkerTest.java
@@ -1,28 +1,145 @@
package com.example.dvely.deployment.infrastructure.worker;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.example.dvely.deployment.application.command.DeploymentCommandService;
+import com.example.dvely.common.worker.WorkerPollGate;
import com.example.dvely.deployment.domain.repository.DeploymentHistoryRepository;
+import java.util.Collection;
import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import static org.assertj.core.api.Assertions.assertThat;
+
import org.junit.jupiter.api.Test;
+import org.springframework.core.task.TaskRejectedException;
class DeploymentRunWorkerTest {
+ private static final long BACKOFF_MS = 5000L;
+
+ /** 백오프가 이 파일의 dispatch 검증에 끼어들지 않도록 게이트를 항상 열어 둔다. */
+ private static WorkerPollGate openGate() {
+ AtomicLong nanos = new AtomicLong();
+ return new WorkerPollGate(1000L, 30_000L, () -> nanos.addAndGet(TimeUnit.MINUTES.toNanos(1)));
+ }
+
@Test
void dispatchPendingDeployments_recoversClaimsAndDelegatesJobs() {
DeploymentHistoryRepository repository = mock(DeploymentHistoryRepository.class);
DeploymentCommandService commandService = mock(DeploymentCommandService.class);
- DeploymentRunWorker worker = new DeploymentRunWorker(repository, commandService);
- when(repository.claimPending(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq(2)))
- .thenReturn(List.of(51L, 52L));
+ DeploymentExecutionRegistry registry = new DeploymentExecutionRegistry();
+ DeploymentRunWorker worker = new DeploymentRunWorker(repository, commandService, openGate(), registry, BACKOFF_MS);
+ when(repository.recoverAndClaimPending(anyString(), eq(2))).thenReturn(List.of(51L, 52L));
worker.dispatchPendingDeployments();
- verify(repository).recoverExpiredLeases();
+ // #340 5-1: 회수와 claim 은 이제 한 트랜잭션(recoverAndClaimPending)이다.
+ verify(repository).recoverAndClaimPending(anyString(), eq(2));
verify(commandService).executeQueued(51L);
verify(commandService).executeQueued(52L);
}
+
+ /**
+ * #340 5-2 재현: {@code deploymentExecutor} 포화로 첫 이력의 위임이 거부되면, 예전에는 그
+ * 예외가 dispatch 루프를 통째로 끊어 같은 배치에서 이미 claim 된 52 번이 영영 실행되지
+ * 않았다. 그 행은 IN_PROGRESS 인 채 리스 만료(2분)까지 남고 사용자 화면에는 "배포 중"이 계속
+ * 떠 있었다. 두 가지를 함께 못박는다 — 뒤 이력은 그대로 위임되고, 거부된 이력은 claim 을
+ * 붙든 채 방치되지 않는다.
+ */
+ @Test
+ void executorRejectionDoesNotStrandTheRestOfTheClaimedBatch() {
+ DeploymentHistoryRepository repository = mock(DeploymentHistoryRepository.class);
+ DeploymentCommandService commandService = mock(DeploymentCommandService.class);
+ DeploymentExecutionRegistry registry = new DeploymentExecutionRegistry();
+ DeploymentRunWorker worker = new DeploymentRunWorker(repository, commandService, openGate(), registry, BACKOFF_MS);
+ when(repository.recoverAndClaimPending(anyString(), eq(2))).thenReturn(List.of(51L, 52L));
+ doThrow(new TaskRejectedException("deploymentExecutor 포화"))
+ .when(commandService).executeQueued(51L);
+
+ worker.dispatchPendingDeployments();
+
+ verify(commandService).executeQueued(52L);
+ verify(repository).releaseClaim(eq(51L), anyString(), eq(BACKOFF_MS));
+ verify(repository, never()).releaseClaim(eq(52L), anyString(), anyLong());
+ }
+
+ /** 제출 전 어떤 예외든 결론은 같다 — 이 이력은 돌지 않으므로 claim 을 붙들고 있으면 안 된다. */
+ @Test
+ void anyPreSubmissionFailureReleasesTheClaimToo() {
+ DeploymentHistoryRepository repository = mock(DeploymentHistoryRepository.class);
+ DeploymentCommandService commandService = mock(DeploymentCommandService.class);
+ DeploymentExecutionRegistry registry = new DeploymentExecutionRegistry();
+ DeploymentRunWorker worker = new DeploymentRunWorker(repository, commandService, openGate(), registry, BACKOFF_MS);
+ when(repository.recoverAndClaimPending(anyString(), eq(2))).thenReturn(List.of(51L));
+ doThrow(new IllegalStateException("제출 전 예외")).when(commandService).executeQueued(51L);
+
+ worker.dispatchPendingDeployments();
+
+ verify(repository).releaseClaim(eq(51L), anyString(), eq(BACKOFF_MS));
+ }
+
+ // ── #340 5-8: 하트비트를 실제 실행 중인 이력으로 좁힌다 ──────────────────────────────────
+
+ @Test
+ void heartbeatSkipsTheRenewalQueryWhenNothingIsExecuting() {
+ DeploymentHistoryRepository repository = mock(DeploymentHistoryRepository.class);
+ DeploymentCommandService commandService = mock(DeploymentCommandService.class);
+ DeploymentExecutionRegistry registry = new DeploymentExecutionRegistry();
+ DeploymentRunWorker worker =
+ new DeploymentRunWorker(repository, commandService, openGate(), registry, BACKOFF_MS);
+
+ worker.renewLeases();
+
+ // 실행 중인 배포가 없는데 0행짜리 UPDATE 를 30초마다 내보낼 이유가 없다.
+ verify(repository, never()).renewLeases(anyString(), org.mockito.ArgumentMatchers.anyCollection());
+ }
+
+ @Test
+ void heartbeatRenewsExactlyTheRegisteredHistoryIds() {
+ DeploymentHistoryRepository repository = mock(DeploymentHistoryRepository.class);
+ DeploymentCommandService commandService = mock(DeploymentCommandService.class);
+ DeploymentExecutionRegistry registry = new DeploymentExecutionRegistry();
+ registry.register(51L);
+ registry.register(52L);
+ DeploymentRunWorker worker =
+ new DeploymentRunWorker(repository, commandService, openGate(), registry, BACKOFF_MS);
+
+ worker.renewLeases();
+
+ @SuppressWarnings("unchecked")
+ org.mockito.ArgumentCaptor> captor =
+ org.mockito.ArgumentCaptor.forClass(Collection.class);
+ verify(repository).renewLeases(anyString(), captor.capture());
+ assertThat(captor.getValue()).containsExactlyInAnyOrder(51L, 52L);
+ }
+
+ /**
+ * 실행기가 거부한 이력은 하트비트 대상에서 빠져야 한다. 안 빠지면 실행 주체가 없는 행의
+ * 리스를 30초마다 되살리게 되고, recoverExpiredLeases 가 그것을 영영 회수하지 못한다.
+ */
+ @Test
+ void aRejectedDispatchLeavesNothingBehindForTheHeartbeatToRenew() {
+ DeploymentHistoryRepository repository = mock(DeploymentHistoryRepository.class);
+ DeploymentCommandService commandService = mock(DeploymentCommandService.class);
+ DeploymentExecutionRegistry registry = new DeploymentExecutionRegistry();
+ DeploymentRunWorker worker =
+ new DeploymentRunWorker(repository, commandService, openGate(), registry, BACKOFF_MS);
+ when(repository.recoverAndClaimPending(anyString(), eq(2))).thenReturn(List.of(51L));
+ doThrow(new TaskRejectedException("deploymentExecutor 포화"))
+ .when(commandService).executeQueued(51L);
+
+ worker.dispatchPendingDeployments();
+
+ assertThat(registry.snapshot()).isEmpty();
+ worker.renewLeases();
+ verify(repository, never()).renewLeases(anyString(), org.mockito.ArgumentMatchers.anyCollection());
+ }
}
diff --git a/src/test/java/com/example/dvely/deployment/infrastructure/worker/StuckDeploymentRecoveryWorkerTest.java b/src/test/java/com/example/dvely/deployment/infrastructure/worker/StuckDeploymentRecoveryWorkerTest.java
index 8d66680d..5713e653 100644
--- a/src/test/java/com/example/dvely/deployment/infrastructure/worker/StuckDeploymentRecoveryWorkerTest.java
+++ b/src/test/java/com/example/dvely/deployment/infrastructure/worker/StuckDeploymentRecoveryWorkerTest.java
@@ -5,6 +5,7 @@
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -180,6 +181,45 @@ void oneHistoryFailingDoesNotAbortTheRestOfTheBatch() {
verify(outcomeService).applySuccess(healthy, project);
}
+ /**
+ * #340 5-4 — 판정을 못 얻은 이력을 매 주기 다시 묻지 않는다.
+ *
+ * 예전에는 멈춘 배포 한 건이 포기 시각(기본 120분)에 닿을 때까지 매분 GitHub 을
+ * 쳤다 — 한 건당 최대 120회다. GitHub Actions 실행 상태는 초 단위로 바뀌지 않으므로 결론이
+ * 없는 조회가 이어지면 물러난다(1 → 2 → 5 → 10분).
+ */
+ @Test
+ void anInconclusiveLookupIsNotRepeatedOnTheVeryNextSweep() {
+ // 아직 도는 중 = 판정 없음. updatedAt 이 최근이라 포기 시각(120분)에도 닿지 않았다.
+ DeploymentHistory history = stuckHistory(901L, LocalDateTime.now().minusMinutes(20));
+ givenStuck(history);
+ when(projectRepository.findById(11L)).thenReturn(Optional.of(project()));
+ givenActiveToken();
+ when(githubActionsPort.getWorkflowRunStatus("user-token", "octo/repo", 901L))
+ .thenReturn(new GithubActionsPort.WorkflowRunStatus(901L, "in_progress", null));
+
+ worker.recoverStuckDeployments();
+ worker.recoverStuckDeployments();
+ worker.recoverStuckDeployments();
+
+ // 세 번 돌았지만 GitHub 은 한 번만 물었다 — 나머지는 백오프가 걸러냈다.
+ verify(githubActionsPort, times(1))
+ .getWorkflowRunStatus("user-token", "octo/repo", 901L);
+ verify(outcomeService, never()).applyFailure(any(), any(), any(), anyString());
+ }
+
+ /** 프로젝트가 사라진 이력도 매분 다시 확인할 이유가 없다 — 다음 주기에도 없다. */
+ @Test
+ void aHistoryWhoseProjectVanishedIsNotRecheckedEverySweep() {
+ givenStuck(stuckHistory(902L, LocalDateTime.now().minusMinutes(20)));
+ when(projectRepository.findById(11L)).thenReturn(Optional.empty());
+
+ worker.recoverStuckDeployments();
+ worker.recoverStuckDeployments();
+
+ verify(projectRepository, times(1)).findById(11L);
+ }
+
private void givenStuck(DeploymentHistory history) {
when(historyRepository.findDispatchedAwaitingOutcome(any(), eq(20)))
.thenReturn(List.of(history));
diff --git a/src/test/java/com/example/dvely/domainbinding/infrastructure/worker/DomainVerificationWorkerTest.java b/src/test/java/com/example/dvely/domainbinding/infrastructure/worker/DomainVerificationWorkerTest.java
index 13b71c8f..5cdac934 100644
--- a/src/test/java/com/example/dvely/domainbinding/infrastructure/worker/DomainVerificationWorkerTest.java
+++ b/src/test/java/com/example/dvely/domainbinding/infrastructure/worker/DomainVerificationWorkerTest.java
@@ -35,7 +35,10 @@ void setUp() {
worker = new DomainVerificationWorker(
domainBindingRepository,
commandService,
- new DomainVerificationProperties(60000L, 20, 30, 1440)
+ new DomainVerificationProperties(60000L, 20, 30, 1440),
+ // 검증을 호출 스레드에서 그대로 돌린다 — 이 파일이 보는 것은 "무엇을 검증했는가"이지
+ // 어느 스레드에서 돌았는가가 아니다(#340 5-5 로 실서비스는 전용 executor 를 쓴다).
+ Runnable::run
);
}
@@ -54,6 +57,23 @@ void verifiesEveryVerifyingDomain() {
verify(commandService).checkVerificationAsSystem(2L);
}
+ /**
+ * #340 5-5 — 갓 만든 도메인이라도 매 주기(60초) 다시 검증하지는 않는다. 커스텀 도메인은
+ * TTL 이 1440분이라 예전에는 한 건당 최대 1,440회, 그것도 Cloudflare + GitHub + HTTPS 프로브
+ * 세 묶음이 나갔다. 기다리는 대상이 DNS 전파와 호스팅 반영이라 그렇게 촘촘히 볼 이유가 없다.
+ */
+ @Test
+ void aDomainCheckedJustNowIsNotRecheckedOnTheVeryNextSweep() {
+ givenVerifyingDomains(managedSubdomain(1L, "a.qeploy.com", LocalDateTime.now()));
+ when(commandService.checkVerificationAsSystem(anyLong())).thenReturn(result(DomainStatus.VERIFYING));
+
+ worker.verifyPendingDomains();
+ worker.verifyPendingDomains();
+ worker.verifyPendingDomains();
+
+ verify(commandService, org.mockito.Mockito.times(1)).checkVerificationAsSystem(1L);
+ }
+
@Test
void oneDomainFailingDoesNotAbortTheRestOfTheBatch() {
// Cloudflare·GitHub 호출이 섞여 있어 어느 하나는 언제든 실패한다. 다음 주기에 다시
diff --git a/src/test/java/com/example/dvely/webhook/WebhookMergedPollClaimIntegrationTest.java b/src/test/java/com/example/dvely/webhook/WebhookMergedPollClaimIntegrationTest.java
new file mode 100644
index 00000000..38c43a36
--- /dev/null
+++ b/src/test/java/com/example/dvely/webhook/WebhookMergedPollClaimIntegrationTest.java
@@ -0,0 +1,227 @@
+package com.example.dvely.webhook;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.example.dvely.common.worker.WorkQueue;
+import com.example.dvely.common.worker.WorkQueuedEvent;
+import com.example.dvely.common.worker.WorkerPollGate;
+import com.example.dvely.webhook.domain.model.WebhookDelivery;
+import com.example.dvely.webhook.domain.repository.WebhookDeliveryRepository;
+import com.example.dvely.webhook.domain.value.WebhookDeliveryStatus;
+import java.nio.charset.StandardCharsets;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.transaction.support.TransactionTemplate;
+
+/**
+ * #340 5-1 — 폴링 구조를 바꾸고도 깨지지 않아야 하는 두 가지를 실제 MySQL 로 증명한다.
+ *
+ * 1. claim 의 원자성. 회수와 claim 을 한 트랜잭션으로 합쳤어도, 두 워커 인스턴스가
+ * 같은 행을 동시에 노리면 정확히 하나만 집는다. 이 성질은 "후보를 SELECT 한 뒤 행 단위 조건부
+ * UPDATE 로 집고, 영향받은 행 수로 성공을 판정한다"는 구조에서 나온다 — 합치기는 그 구조를
+ * 건드리지 않으므로 성질도 그대로다. 말로 그렇다는 것과 실제로 그런 것은 다르므로 두 스레드로
+ * 재현한다.
+ *
+ * 2. 깨우기의 트랜잭션 경계. 신호는 커밋 뒤에 도착해야 한다. 커밋 전에 깨우면
+ * 워커가 아직 보이지 않는 행을 찾다가 빈손으로 돌아가 도로 백오프에 들어가고, 그 일은 백오프
+ * 상한만큼 늦어진다.
+ *
+ * 이 클래스가 웹훅 큐를 쓰는 이유는 {@code build.gradle} 이 테스트 JVM 전체에서 웹훅 워커의
+ * 폴링을 1시간으로 꺼두기 때문이다 — 살아 있는 워커가 끼어들어 게이트 상태와 행을 흔들지 않는
+ * 유일한 큐다.
+ */
+@SpringBootTest
+class WebhookMergedPollClaimIntegrationTest {
+
+ /** 공유 스키마에 남은 오래된 행에 밀려 갓 심은 행이 배치에서 빠지지 않도록 넉넉히 잡는다. */
+ private static final int POLL_LIMIT = 5_000;
+
+ @Autowired
+ private WebhookDeliveryRepository webhookDeliveryRepository;
+ @Autowired
+ private WorkerPollGate pollGate;
+ @Autowired
+ private ApplicationEventPublisher eventPublisher;
+ @Autowired
+ private TransactionTemplate transactionTemplate;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ // ── 1. claim 원자성 ────────────────────────────────────────────────────────────────────────
+
+ @Test
+ void mergedPollRecoversAndClaimsInOneCall() {
+ String deliveryId = uniqueId("merged-basic");
+ seedDelivery(deliveryId, WebhookDeliveryStatus.PENDING, LocalDateTime.now().minusSeconds(1), null, null);
+
+ List claimed = webhookDeliveryRepository.recoverAndClaimPending("worker-a", POLL_LIMIT);
+
+ assertThat(claimed).contains(deliveryId);
+ Map row = fetchRow(deliveryId);
+ assertThat(row.get("status")).isEqualTo(WebhookDeliveryStatus.PROCESSING.name());
+ assertThat(row.get("lease_owner")).isEqualTo("worker-a");
+ }
+
+ @Test
+ void concurrentMergedPollsFromTwoWorkerInstancesNeverBothWinTheSameDelivery() throws Exception {
+ String deliveryId = uniqueId("merged-race");
+ seedDelivery(deliveryId, WebhookDeliveryStatus.PENDING, LocalDateTime.now().minusSeconds(1), null, null);
+
+ CountDownLatch startBarrier = new CountDownLatch(2);
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ List>> futures;
+ try {
+ futures = pool.invokeAll(List.of(
+ racePoll(startBarrier, "worker-merged-a"),
+ racePoll(startBarrier, "worker-merged-b")
+ ));
+ } finally {
+ pool.shutdown();
+ }
+
+ long winners = 0;
+ for (Future> future : futures) {
+ if (future.get(20, TimeUnit.SECONDS).contains(deliveryId)) {
+ winners++;
+ }
+ }
+ assertThat(winners)
+ .as("회수와 claim 을 한 트랜잭션으로 합쳐도 같은 행을 두 인스턴스가 집으면 안 된다")
+ .isEqualTo(1);
+ Map row = fetchRow(deliveryId);
+ assertThat(row.get("status")).isEqualTo(WebhookDeliveryStatus.PROCESSING.name());
+ assertThat(row.get("lease_owner")).isIn("worker-merged-a", "worker-merged-b");
+ }
+
+ @Test
+ void alreadyClaimedRowIsNotReselectedByAnotherInstancesMergedPoll() {
+ String deliveryId = uniqueId("merged-no-reselect");
+ seedDelivery(deliveryId, WebhookDeliveryStatus.PENDING, LocalDateTime.now().minusSeconds(1), null, null);
+
+ assertThat(webhookDeliveryRepository.recoverAndClaimPending("worker-a", POLL_LIMIT)).contains(deliveryId);
+ assertThat(webhookDeliveryRepository.recoverAndClaimPending("worker-b", POLL_LIMIT))
+ .doesNotContain(deliveryId);
+ assertThat(fetchRow(deliveryId).get("lease_owner")).isEqualTo("worker-a");
+ }
+
+ /**
+ * 회수가 합친 뒤에도 실제로 돌아가는지 — 합치면서 조용히 빠뜨리면 좀비 리스가 영원히 남는다.
+ *
+ * 합쳤기 때문에 두 결과가 모두 정상이다: 회수만 되어 RETRY_WAIT 로 남거나, 회수된
+ * 행을 같은 호출의 claim 이 곧바로 다시 집거나. {@code recoverExpiredLease} 가 next_attempt_at
+ * 을 "지금"으로 적는데 그 컬럼이 초 단위 DATETIME 이라, 반올림이 위로 튀느냐에 따라 같은
+ * 트랜잭션의 claim 이 집기도 하고 못 집기도 한다. 그래서 상태 대신 회수가 남긴 흔적으로
+ * 판정한다 — 회수만이 이 error_message 를 적고, 이어지는 claim 은 그것을 지우지 않는다.
+ */
+ @Test
+ void mergedPollStillRecoversAnExpiredLease() {
+ String deliveryId = uniqueId("merged-recover");
+ seedDelivery(deliveryId, WebhookDeliveryStatus.PROCESSING, null,
+ "worker-dead", LocalDateTime.now().minusMinutes(5));
+
+ webhookDeliveryRepository.recoverAndClaimPending("worker-alive", POLL_LIMIT);
+
+ Map row = fetchRow(deliveryId);
+ assertThat((String) row.get("error_message"))
+ .as("회수가 돌지 않았다면 이 문구는 적히지 않는다")
+ .isEqualTo("worker lease가 만료되어 webhook 처리를 다시 시도합니다.");
+ assertThat(row.get("lease_owner"))
+ .as("죽은 워커의 리스를 남겨두면 그 배달은 영영 처리되지 않는다")
+ .isIn(null, "worker-alive");
+ assertThat(row.get("status")).isIn(
+ WebhookDeliveryStatus.RETRY_WAIT.name(), WebhookDeliveryStatus.PROCESSING.name());
+ }
+
+ // ── 2. 깨우기의 트랜잭션 경계 ────────────────────────────────────────────────────────────────
+
+ @Test
+ void theWakeupSignalArrivesOnlyAfterTheEnqueueCommits() {
+ backOffFully();
+
+ transactionTemplate.executeWithoutResult(status -> {
+ eventPublisher.publishEvent(new WorkQueuedEvent(WorkQueue.WEBHOOK_DELIVERY));
+ assertThat(pollGate.shouldPoll(WorkQueue.WEBHOOK_DELIVERY))
+ .as("커밋 전에 깨우면 워커가 아직 보이지 않는 행을 찾다가 도로 물러난다")
+ .isFalse();
+ });
+
+ assertThat(pollGate.shouldPoll(WorkQueue.WEBHOOK_DELIVERY))
+ .as("커밋 직후에는 백오프가 얼마였든 다음 틱에 폴링해야 한다")
+ .isTrue();
+ }
+
+ @Test
+ void enqueueingARealDeliveryWakesTheWorkerQueue() {
+ backOffFully();
+
+ boolean accepted = webhookDeliveryRepository.enqueue(new WebhookDelivery(
+ uniqueId("merged-wake"), "push", "{}".getBytes(StandardCharsets.UTF_8)));
+
+ assertThat(accepted).isTrue();
+ assertThat(pollGate.shouldPoll(WorkQueue.WEBHOOK_DELIVERY))
+ .as("실제 enqueue 경로가 깨우기 신호를 내보내야 한다 — 아니면 배달이 백오프 상한만큼 방치된다")
+ .isTrue();
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * 게이트를 상한까지 물러나게 만든다. {@code recordIdle} 을 반복하면 간격이 두 배씩 늘어 상한에
+ * 닿는다 — 벽시계를 기다리지 않고 "충분히 물러난 상태"를 만드는 방법이다. 앞의
+ * {@code shouldPoll} 은 세대를 맞추기 위한 것으로, 이것이 없으면 이전 테스트가 남긴 깨우기
+ * 때문에 {@code recordIdle} 이 조용히 무시된다.
+ */
+ private void backOffFully() {
+ pollGate.shouldPoll(WorkQueue.WEBHOOK_DELIVERY);
+ for (int i = 0; i < 6; i++) {
+ pollGate.recordIdle(WorkQueue.WEBHOOK_DELIVERY);
+ }
+ assertThat(pollGate.shouldPoll(WorkQueue.WEBHOOK_DELIVERY)).isFalse();
+ }
+
+ private Callable> racePoll(CountDownLatch startBarrier, String workerId) {
+ return () -> {
+ startBarrier.countDown();
+ startBarrier.await();
+ return webhookDeliveryRepository.recoverAndClaimPending(workerId, POLL_LIMIT);
+ };
+ }
+
+ private String uniqueId(String label) {
+ String raw = "it-340-" + label + "-" + System.nanoTime();
+ return raw.length() <= 64 ? raw : raw.substring(0, 64);
+ }
+
+ private void seedDelivery(String deliveryId, WebhookDeliveryStatus status, LocalDateTime nextAttemptAt,
+ String leaseOwner, LocalDateTime leaseUntil) {
+ jdbcTemplate.update(
+ """
+ insert into webhook_deliveries
+ (delivery_id, event_type, payload, status, attempt, max_attempts,
+ next_attempt_at, lease_owner, lease_until)
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ deliveryId, "issues", "{}".getBytes(StandardCharsets.UTF_8), status.name(), 0, 5,
+ nextAttemptAt == null ? null : Timestamp.valueOf(nextAttemptAt),
+ leaseOwner,
+ leaseUntil == null ? null : Timestamp.valueOf(leaseUntil)
+ );
+ }
+
+ private Map fetchRow(String deliveryId) {
+ return jdbcTemplate.queryForMap("select * from webhook_deliveries where delivery_id = ?", deliveryId);
+ }
+}
diff --git a/src/test/java/com/example/dvely/webhook/infrastructure/worker/WebhookDeliveryWorkerTest.java b/src/test/java/com/example/dvely/webhook/infrastructure/worker/WebhookDeliveryWorkerTest.java
new file mode 100644
index 00000000..efc689a0
--- /dev/null
+++ b/src/test/java/com/example/dvely/webhook/infrastructure/worker/WebhookDeliveryWorkerTest.java
@@ -0,0 +1,110 @@
+package com.example.dvely.webhook.infrastructure.worker;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.example.dvely.common.worker.WorkerPollGate;
+import com.example.dvely.webhook.application.WebhookService;
+import com.example.dvely.webhook.domain.repository.WebhookDeliveryRepository;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.task.TaskRejectedException;
+
+/**
+ * #340 5-3 — 웹훅 배달 처리를 스케줄러 스레드에서 떼어낸 뒤의 계약.
+ */
+class WebhookDeliveryWorkerTest {
+
+ private static final long BACKOFF_MS = 5_000L;
+
+ private final WebhookDeliveryRepository repository = mock(WebhookDeliveryRepository.class);
+ private final WebhookService webhookService = mock(WebhookService.class);
+
+ /** 백오프가 이 파일의 dispatch 검증에 끼어들지 않도록 게이트를 항상 열어 둔다. */
+ private static WorkerPollGate openGate() {
+ AtomicLong nanos = new AtomicLong();
+ return new WorkerPollGate(1000L, 30_000L, () -> nanos.addAndGet(TimeUnit.MINUTES.toNanos(1)));
+ }
+
+ private WebhookDeliveryWorker worker(Executor executor) {
+ return new WebhookDeliveryWorker(repository, webhookService, openGate(), executor, BACKOFF_MS);
+ }
+
+ @Test
+ void deliveriesAreHandedToTheExecutorInsteadOfRunningOnTheSchedulerThread() {
+ List submitted = new ArrayList<>();
+ WebhookDeliveryWorker worker = worker(submitted::add);
+ when(repository.recoverAndClaimPending(anyString(), eq(10)))
+ .thenReturn(List.of("delivery-1", "delivery-2"));
+
+ worker.dispatchPendingDeliveries();
+
+ // 스케줄러 스레드에서는 아무것도 처리하지 않는다 — 넘기기만 한다.
+ assertThat(submitted).hasSize(2);
+ verify(webhookService, never()).processDelivery(anyString());
+
+ submitted.forEach(Runnable::run);
+ verify(webhookService).processDelivery("delivery-1");
+ verify(webhookService).processDelivery("delivery-2");
+ }
+
+ /**
+ * 비동기로 넘기면 {@code TaskRejectedException} 이라는 새 실패 경로가 생긴다. 그것이 루프를
+ * 끊으면 같은 배치의 뒤 배달이 PROCESSING 인 채 리스 만료(2분)까지 남는다 — 배포·클라우드
+ * 워커에 있었던 것과 똑같은 버그를 여기서 새로 만들지 않는다.
+ */
+ @Test
+ void anExecutorRejectionReleasesThatClaimAndStillDeliversTheRest() {
+ List submitted = new ArrayList<>();
+ Executor rejectsFirst = new Executor() {
+ private boolean first = true;
+
+ @Override
+ public void execute(Runnable command) {
+ if (first) {
+ first = false;
+ throw new TaskRejectedException("webhookExecutor 포화");
+ }
+ submitted.add(command);
+ }
+ };
+ WebhookDeliveryWorker worker = worker(rejectsFirst);
+ when(repository.recoverAndClaimPending(anyString(), eq(10)))
+ .thenReturn(List.of("delivery-1", "delivery-2"));
+
+ worker.dispatchPendingDeliveries();
+
+ assertThat(submitted).hasSize(1);
+ verify(repository).releaseClaim(eq("delivery-1"), anyString(), eq(BACKOFF_MS));
+ verify(repository, never()).releaseClaim(eq("delivery-2"), anyString(), anyLong());
+ }
+
+ /** executor 스레드에서 튀어나온 예외가 조용히 사라지면 안 된다 — 워커가 잡아 남긴다. */
+ @Test
+ void anUnexpectedFailureInsideTheExecutorTaskDoesNotEscape() {
+ List submitted = new ArrayList<>();
+ WebhookDeliveryWorker worker = worker(submitted::add);
+ when(repository.recoverAndClaimPending(anyString(), eq(10))).thenReturn(List.of("delivery-1"));
+ doThrowOnProcess();
+
+ worker.dispatchPendingDeliveries();
+
+ assertThat(submitted).hasSize(1);
+ submitted.get(0).run(); // 예외가 밖으로 나오면 이 줄에서 테스트가 깨진다
+ }
+
+ private void doThrowOnProcess() {
+ org.mockito.Mockito.doThrow(new IllegalStateException("배달 행이 사라졌다"))
+ .when(webhookService).processDelivery("delivery-1");
+ }
+}