fix: schedule manual outbox retries in UTC

This commit is contained in:
2026-10-03 09:27:46 +08:00
parent 588227f181
commit d93185e750
3 changed files with 90 additions and 1 deletions
@@ -7,6 +7,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -76,7 +77,8 @@ public final class OutboxOperations {
return result(canonicalId, identity, request, true);
}
if (!"DEAD".equals(row.get("entry_status"))) throw conflict("仅允许重试 DEAD 事件");
LocalDateTime now = LocalDateTime.now();
// Relay due-time comparisons use UTC, independently of the JVM default zone.
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
int updated = jdbc.update("UPDATE " + layout.table() + " SET " + layout.statusColumn()
+ "='PENDING',retry_count=0,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL WHERE "
+ layout.idColumn() + "=? AND " + layout.statusColumn() + "='DEAD'", now, canonicalId);
@@ -5,15 +5,20 @@ import com.baiyue.common.core.outbox.OutboxOperations;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Isolated;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Set;
import java.util.TimeZone;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.*;
@Isolated("UTC retry regression temporarily changes the JVM default time zone")
class OutboxOperationsTest {
private static final String HASH = "sha256:" + "a".repeat(64);
private JdbcTemplate jdbc;
@@ -54,6 +59,35 @@ class OutboxOperationsTest {
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero();
}
@Test void retryIsImmediatelyDueInUtcUnderShanghaiDefaultZoneAndReplayKeepsTimes() {
TimeZone original = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
var request = request();
LocalDateTime before = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1);
assertThat(operations.retry("1", request).duplicate()).isFalse();
LocalDateTime nextRetry = jdbc.queryForObject("SELECT next_retry_at FROM events WHERE id='1'",
(row, index) -> row.getObject(1, LocalDateTime.class));
LocalDateTime requested = jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit",
(row, index) -> row.getObject(1, LocalDateTime.class));
LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC);
// Allow database timestamp precision rounding, but never a default-zone offset.
assertThat(nextRetry).isBetween(before, scan.plusSeconds(1));
assertThat(requested).isEqualTo(nextRetry);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM events WHERE id='1' AND status='PENDING' AND next_retry_at<=?",
Integer.class, scan)).isEqualTo(1);
assertThat(operations.retry("1", request).duplicate()).isTrue();
assertThat(jdbc.<LocalDateTime>queryForObject("SELECT next_retry_at FROM events WHERE id='1'",
(row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(nextRetry);
assertThat(jdbc.<LocalDateTime>queryForObject("SELECT requested_at FROM event_delivery_retry_audit",
(row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(requested);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT payload_json FROM events WHERE id='1'", String.class)).isEqualTo("event-1");
} finally {
TimeZone.setDefault(original);
}
}
@Test void staleHashAndNonDeadAndReusedRequestAreRejected() {
var original = request();
var stale = new OutboxOperations.RetryRequest(original.requestId(), original.operator(), original.reason(), "sha256:" + "b".repeat(64));