From a25684ca0802dcd63afe27efc313a76c47851efa Mon Sep 17 00:00:00 2001 From: dldnsgkr Date: Wed, 19 Aug 2026 21:45:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20GitHub=20Actions=20run=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EA=B0=80=209=EC=8B=9C=EA=B0=84=20=EB=AF=B8?= =?UTF-8?q?=EB=9E=98=EB=A5=BC=20=EB=B3=B4=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit created=>= 필터를 만들 때 atOffset(UTC) 를 써서, KST 벽시계로 저장된 LocalDateTime 에 UTC 라벨만 붙였다. GitHub 은 진짜 UTC 로 필터링하므로 우리가 찾으려는 실행은 항상 범위 밖으로 밀려나고 결과는 언제나 0건이었다. 운영 실측(2026-08-19): triggeredAt 2026-08-18T14:37:40(KST) 인 배포를 created>=2026-08-18T14:36:40Z → 0건 (현재 코드가 보내던 값) created>=2026-08-18T05:36:40Z → 3건, 첫 건이 completed success 대가가 두 가지였다. 1. 디스패치 직후 findWorkflowRun/pollWorkflowRun 이 실행을 못 찾는다. 그래서 pollWorkflowRun 이 5회 × 3초를 헛돌고 runId 없이 IN_PROGRESS 로 넘어간다. 지금까지 배포가 정상으로 보인 것은 웹훅이 correlationId 로 매칭해준 덕이다. 2. 웹훅을 놓친 이력을 회수하려던 StuckDeploymentRecoveryWorker 도 같은 이유로 실행을 못 찾아, 실제로는 성공한 배포를 "결과를 확인할 수 없습니다"로 닫았다. 운영에서 어제 성공한 배포 하나가 오늘 그렇게 FAILED 가 됐다. 벽시계에 라벨을 붙이지 말고 그 타임존의 순간으로 해석한다. 시스템 타임존을 쓰므로 호스트가 UTC 든 KST 든 자기 DB 값과 어긋나지 않는다. Co-Authored-By: Claude Opus 5 (1M context) --- .../external/GithubActionsClient.java | 33 +++++++--- .../external/GithubActionsClientTest.java | 62 +++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/example/dvely/deployment/infrastructure/external/GithubActionsClientTest.java diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/external/GithubActionsClient.java b/src/main/java/com/example/dvely/deployment/infrastructure/external/GithubActionsClient.java index a9f8150..0bba247 100644 --- a/src/main/java/com/example/dvely/deployment/infrastructure/external/GithubActionsClient.java +++ b/src/main/java/com/example/dvely/deployment/infrastructure/external/GithubActionsClient.java @@ -10,7 +10,7 @@ import org.springframework.web.client.RestClientResponseException; import java.time.LocalDateTime; -import java.time.ZoneOffset; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Base64; import java.util.List; @@ -147,9 +147,7 @@ private FileContentResponse getFileContent(String userToken, String owner, Strin public WorkflowRunStatus getLatestRunStatus(String userToken, String repoFullName, String workflowFileName, LocalDateTime afterTime) { String[] parts = splitRepo(repoFullName); - String createdFilter = afterTime.minusMinutes(1) - .atOffset(ZoneOffset.UTC) - .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + String createdFilter = createdFilter(afterTime, ZoneId.systemDefault()); try { WorkflowRunsResponse response = restClient(userToken) .get() @@ -254,9 +252,7 @@ private WorkflowRunsResponse getWorkflowRuns(String userToken, String workflowFileName, LocalDateTime afterTime) { String[] parts = splitRepo(repoFullName); - String createdFilter = afterTime.minusMinutes(1) - .atOffset(ZoneOffset.UTC) - .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + String createdFilter = createdFilter(afterTime, ZoneId.systemDefault()); try { return restClient(userToken) .get() @@ -270,6 +266,29 @@ private WorkflowRunsResponse getWorkflowRuns(String userToken, } } + /** + * GitHub Actions run 목록의 {@code created=>=} 필터 값을 만든다. + * + * afterTime 은 우리 DB 의 LocalDateTime 이고, 그 값은 호스트 타임존(KST)의 벽시계다. + * 여기서 atOffset(UTC) 를 쓰면 그 벽시계에 UTC 라벨만 붙어 실제보다 9시간 미래의 순간이 + * 된다. GitHub 은 진짜 UTC 로 필터링하므로 우리가 찾으려는 실행은 항상 범위 밖으로 + * 밀려나고, 결과는 언제나 0건이 된다. + * + * 실측(2026-08-19 운영): triggeredAt 2026-08-18T14:37:40(KST) 인 배포를 + * created>=2026-08-18T14:36:40Z 로 조회 → 0건 + * created>=2026-08-18T05:36:40Z 로 조회 → 3건, 첫 건이 completed success + * 이 때문에 디스패치 직후 매칭이 늘 실패해 runId 없이 IN_PROGRESS 로 넘어갔고, + * 웹훅을 놓친 이력을 회수하려던 워커도 실행을 못 찾아 성공한 배포를 FAILED 로 닫았다. + * + * 그러니 라벨을 붙이지 말고 벽시계를 그 타임존의 순간으로 해석해야 한다. + */ + static String createdFilter(LocalDateTime afterTime, ZoneId zone) { + return afterTime.minusMinutes(1) + .atZone(zone) + .toOffsetDateTime() + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + private void sleep(long retryIntervalMs) { try { Thread.sleep(retryIntervalMs); diff --git a/src/test/java/com/example/dvely/deployment/infrastructure/external/GithubActionsClientTest.java b/src/test/java/com/example/dvely/deployment/infrastructure/external/GithubActionsClientTest.java new file mode 100644 index 0000000..3c25ab4 --- /dev/null +++ b/src/test/java/com/example/dvely/deployment/infrastructure/external/GithubActionsClientTest.java @@ -0,0 +1,62 @@ +package com.example.dvely.deployment.infrastructure.external; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import org.junit.jupiter.api.Test; + +class GithubActionsClientTest { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + + @Test + void createdFilterConvertsWallClockToTheRealInstant() { + // DB 의 LocalDateTime 은 호스트 타임존(KST)의 벽시계다. GitHub 은 진짜 UTC 로 + // 필터링하므로 그 벽시계를 KST 의 순간으로 해석해 넘겨야 한다. + LocalDateTime triggeredAt = LocalDateTime.of(2026, 8, 18, 14, 37, 40); + + String filter = GithubActionsClient.createdFilter(triggeredAt, SEOUL); + + // 14:36:40 KST == 05:36:40 UTC. 라벨만 바꾸면 14:36:40Z 가 되어 9시간 미래를 본다. + assertThat(filter).isEqualTo("2026-08-18T14:36:40+09:00"); + assertThat(java.time.OffsetDateTime.parse(filter).toInstant()) + .isEqualTo(java.time.Instant.parse("2026-08-18T05:36:40Z")); + } + + @Test + void theFilterNeverExcludesTheRunItIsLookingFor() { + // 실측(2026-08-19 운영): 이 배포의 실행은 05:38:03Z 에 있었는데, 잘못된 변환은 + // 14:36:40Z 부터를 요구해 실행을 범위 밖으로 밀어냈다 — 조회 결과가 늘 0건이었다. + LocalDateTime triggeredAt = LocalDateTime.of(2026, 8, 18, 14, 37, 40); + java.time.Instant actualRunCreatedAt = java.time.Instant.parse("2026-08-18T05:38:03Z"); + + java.time.Instant filterFrom = + java.time.OffsetDateTime.parse(GithubActionsClient.createdFilter(triggeredAt, SEOUL)).toInstant(); + + assertThat(filterFrom).isBefore(actualRunCreatedAt); + } + + @Test + void aOneMinuteMarginIsKeptSoARunStartedJustBeforeTheRecordStillMatches() { + // 이력의 triggeredAt 과 GitHub 이 실행을 만든 시각은 몇 초 어긋날 수 있다. + LocalDateTime triggeredAt = LocalDateTime.of(2026, 8, 18, 14, 37, 40); + + String filter = GithubActionsClient.createdFilter(triggeredAt, SEOUL); + + assertThat(java.time.OffsetDateTime.parse(filter).toLocalDateTime()) + .isEqualTo(triggeredAt.minusMinutes(1)); + } + + @Test + void aUtcHostProducesTheSameInstantForItsOwnWallClock() { + // 호스트가 UTC 로 떠 있으면 그 DB 값도 UTC 벽시계다. 시스템 타임존을 그대로 쓰므로 + // 어느 쪽이든 순간이 어긋나지 않는다. + LocalDateTime triggeredAt = LocalDateTime.of(2026, 8, 18, 5, 37, 40); + + String filter = GithubActionsClient.createdFilter(triggeredAt, ZoneId.of("UTC")); + + assertThat(java.time.OffsetDateTime.parse(filter).toInstant()) + .isEqualTo(java.time.Instant.parse("2026-08-18T05:36:40Z")); + } +}