From 441dc753046f1387b688024117a631fa68fb1066 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 4 Sep 2026 12:18:52 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat(user):=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20backfill=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/hashi/user/domain/User.java | 12 + .../hashi/user/domain/UserRepository.java | 10 + .../UserProfileBackfillAttachmentService.java | 58 ++ .../UserProfileBackfillCandidate.java | 20 + .../UserProfileBackfillCandidateReader.java | 46 ++ .../UserProfileBackfillCheckpointStore.java | 307 ++++++++++ .../UserProfileBackfillConfiguration.java | 36 ++ ...UserProfileBackfillLeaseLostException.java | 8 + .../migration/UserProfileBackfillMode.java | 12 + .../migration/UserProfileBackfillOutcome.java | 9 + .../UserProfileBackfillProperties.java | 78 +++ .../migration/UserProfileBackfillRunner.java | 317 ++++++++++ .../migration/UserProfileBackfillSummary.java | 38 ++ src/main/resources/application.yml | 11 + ...reate_user_profile_backfill_checkpoint.sql | 36 ++ .../user/domain/UserProfileImageTest.java | 52 ++ ...rProfileBackfillAttachmentServiceTest.java | 110 ++++ .../UserProfileBackfillLifecycleTest.java | 95 +++ ...ileBackfillPersistenceIntegrationTest.java | 548 ++++++++++++++++++ .../UserProfileBackfillPropertiesTest.java | 75 +++ .../UserProfileBackfillRunnerTest.java | 264 +++++++++ 21 files changed, 2142 insertions(+) create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillAttachmentService.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCandidate.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCandidateReader.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCheckpointStore.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillConfiguration.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillLeaseLostException.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillMode.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillOutcome.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillProperties.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java create mode 100644 src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillSummary.java create mode 100644 src/main/resources/db/migration/V23__create_user_profile_backfill_checkpoint.sql create mode 100644 src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillAttachmentServiceTest.java create mode 100644 src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillLifecycleTest.java create mode 100644 src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java create mode 100644 src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPropertiesTest.java create mode 100644 src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java diff --git a/src/main/java/org/sopt/hashi/user/domain/User.java b/src/main/java/org/sopt/hashi/user/domain/User.java index 84353fec..4e6bd1a0 100644 --- a/src/main/java/org/sopt/hashi/user/domain/User.java +++ b/src/main/java/org/sopt/hashi/user/domain/User.java @@ -109,4 +109,16 @@ public void assignOnboardingProfileImage(UUID assetId) { } profileImageAssetId = assetId; } + + /** migration이 User를 잠근 뒤 호출한다. 기존 회원 정보와 legacy key는 그대로 보존한다. */ + public boolean attachBackfilledProfileImage(String expectedKey, UUID assetId) { + Objects.requireNonNull(assetId, "assetId must not be null"); + boolean unchangedSource = !deleted && profileImageKey != null + && profileImageKey.equals(expectedKey) && profileImageAssetId == null; + if (!unchangedSource) { + return false; + } + profileImageAssetId = assetId; + return true; + } } diff --git a/src/main/java/org/sopt/hashi/user/domain/UserRepository.java b/src/main/java/org/sopt/hashi/user/domain/UserRepository.java index b6c18e43..d5a162b3 100644 --- a/src/main/java/org/sopt/hashi/user/domain/UserRepository.java +++ b/src/main/java/org/sopt/hashi/user/domain/UserRepository.java @@ -1,11 +1,21 @@ package org.sopt.hashi.user.domain; +import jakarta.persistence.LockModeType; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface UserRepository extends JpaRepository { + /** User의 SQLRestriction을 유지해 탈퇴 회원은 잠금 조회에서도 제외한다. */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from User u where u.id = :userId") + Optional findByIdForUpdate(@Param("userId") Long userId); + Page findByNicknameContaining(String nickname, Pageable pageable); boolean existsByNickname(String nickname); diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillAttachmentService.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillAttachmentService.java new file mode 100644 index 00000000..97875a76 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillAttachmentService.java @@ -0,0 +1,58 @@ +package org.sopt.hashi.user.migration; + +import java.time.Duration; +import java.util.List; +import org.sopt.hashi.media.MediaAssetPurpose; +import org.sopt.hashi.media.MediaBackfillAssetInfo; +import org.sopt.hashi.media.MediaBackfillClaim; +import org.sopt.hashi.media.MediaBackfillPort; +import org.sopt.hashi.user.domain.User; +import org.sopt.hashi.user.domain.UserRepository; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Lease; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +class UserProfileBackfillAttachmentService { + + private final UserRepository userRepository; + private final MediaBackfillPort mediaBackfillPort; + private final UserProfileBackfillCheckpointStore checkpointStore; + + UserProfileBackfillAttachmentService( + UserRepository userRepository, + MediaBackfillPort mediaBackfillPort, + UserProfileBackfillCheckpointStore checkpointStore + ) { + this.userRepository = userRepository; + this.mediaBackfillPort = mediaBackfillPort; + this.checkpointStore = checkpointStore; + } + + /** User 변경, media claim과 fenced cursor 전진은 모두 같은 쓰기 transaction에 참여한다. */ + @Transactional + public UserProfileBackfillOutcome attachAndRecord( + UserProfileBackfillCandidate candidate, + MediaBackfillAssetInfo asset, + Lease lease, + Duration leaseDuration + ) { + boolean valid = lease.mode() == UserProfileBackfillMode.ATTACH + && asset.state() == MediaBackfillAssetInfo.State.READY + && asset.purpose() == MediaAssetPurpose.PROFILE; + if (!valid) { + throw new IllegalArgumentException("invalid user profile backfill attachment"); + } + User user = userRepository.findByIdForUpdate(candidate.userId()).orElse(null); + boolean attached = user != null + && user.attachBackfilledProfileImage(candidate.legacyKey(), asset.assetId()); + UserProfileBackfillOutcome outcome = attached + ? UserProfileBackfillOutcome.ATTACHED : UserProfileBackfillOutcome.SKIPPED; + if (attached) { + mediaBackfillPort.claimReady(List.of( + new MediaBackfillClaim(asset.assetId(), asset.purpose(), asset.identityHash()))); + } + checkpointStore.recordProgress(lease, candidate.userId(), outcome, leaseDuration); + return outcome; + } +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCandidate.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCandidate.java new file mode 100644 index 00000000..342326b6 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCandidate.java @@ -0,0 +1,20 @@ +package org.sopt.hashi.user.migration; + +/** 프로필 슬롯 조사에 필요한 최소 입력만 보관하며 개인정보와 경로를 출력하지 않는다. */ +record UserProfileBackfillCandidate(long userId, String legacyKey) { + + UserProfileBackfillCandidate { + if (userId < 1 || legacyKey == null) { + throw new IllegalArgumentException("invalid user profile backfill candidate"); + } + } + + boolean hasUsableLegacyKey() { + return !legacyKey.isBlank(); + } + + @Override + public String toString() { + return "UserProfileBackfillCandidate[redacted]"; + } +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCandidateReader.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCandidateReader.java new file mode 100644 index 00000000..b0e44997 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCandidateReader.java @@ -0,0 +1,46 @@ +package org.sopt.hashi.user.migration; + +import java.util.List; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +class UserProfileBackfillCandidateReader { + + private final JdbcTemplate jdbcTemplate; + + UserProfileBackfillCandidateReader(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional(readOnly = true) + public long findUpperBound() { + Long upperBound = jdbcTemplate.queryForObject(""" + SELECT COALESCE(MAX(id), 0) + FROM users + WHERE deleted = FALSE + AND profile_image_key IS NOT NULL + AND profile_image_asset_id IS NULL + """, Long.class); + return upperBound == null ? 0L : upperBound; + } + + @Transactional(readOnly = true) + public List findBatch(long cursor, long upperBound, int limit) { + if (cursor < 0 || upperBound < cursor || limit < 1) { + throw new IllegalArgumentException("invalid user profile backfill range"); + } + return jdbcTemplate.query(""" + SELECT id, profile_image_key + FROM users + WHERE deleted = FALSE + AND profile_image_key IS NOT NULL + AND profile_image_asset_id IS NULL + AND id > ? AND id <= ? + ORDER BY id ASC + LIMIT ? + """, (row, rowNumber) -> new UserProfileBackfillCandidate( + row.getLong("id"), row.getString("profile_image_key")), cursor, upperBound, limit); + } +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCheckpointStore.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCheckpointStore.java new file mode 100644 index 00000000..c6062743 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCheckpointStore.java @@ -0,0 +1,307 @@ +package org.sopt.hashi.user.migration; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +class UserProfileBackfillCheckpointStore { + + private static final String INSERT_IF_ABSENT_SQL = """ + INSERT INTO user_profile_backfill_checkpoint ( + run_id, mode, status, upper_bound_id, cursor_id, + scanned_count, prepared_count, attached_count, skipped_count, failed_count + ) VALUES (?, ?, 'PAUSED', ?, 0, 0, 0, 0, 0, 0) + ON DUPLICATE KEY UPDATE run_id = run_id + """; + private static final String SELECT_SQL = """ + SELECT run_id, mode, status, upper_bound_id, cursor_id, + lease_token, lease_until, scanned_count, prepared_count, + attached_count, skipped_count, failed_count + FROM user_profile_backfill_checkpoint + WHERE run_id = ? + """; + private static final String SELECT_FOR_UPDATE_SQL = SELECT_SQL + " FOR UPDATE"; + + private final JdbcTemplate jdbcTemplate; + + UserProfileBackfillCheckpointStore(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public Acquisition acquire( + UUID runId, + UserProfileBackfillMode mode, + long initialUpperBound, + Duration leaseDuration + ) { + requirePersistentMode(mode); + if (initialUpperBound < 0) { + throw new IllegalArgumentException("initialUpperBound must not be negative"); + } + jdbcTemplate.update( + INSERT_IF_ABSENT_SQL, + runId.toString(), mode.name(), initialUpperBound + ); + Snapshot snapshot = findForUpdate(runId); + validateIdentity(snapshot, mode); + if (snapshot.status() == Status.COMPLETED) { + return new Acquisition(AcquisitionState.COMPLETED, null, snapshot); + } + + LocalDateTime databaseNow = databaseNow(); + boolean heldByAnotherWorker = snapshot.status() == Status.RUNNING + && snapshot.leaseUntil() != null + && snapshot.leaseUntil().isAfter(databaseNow); + if (heldByAnotherWorker) { + return new Acquisition(AcquisitionState.BUSY, null, snapshot); + } + + UUID leaseToken = UUID.randomUUID(); + int updated = jdbcTemplate.update(""" + UPDATE user_profile_backfill_checkpoint + SET status = 'RUNNING', + lease_token = ?, + lease_until = TIMESTAMPADD(MICROSECOND, ?, CURRENT_TIMESTAMP(6)), + updated_at = CURRENT_TIMESTAMP(6) + WHERE run_id = ? + """, + leaseToken.toString(), leaseMicros(leaseDuration), runId.toString()); + if (updated != 1) { + throw new UserProfileBackfillLeaseLostException(); + } + Snapshot acquired = findForUpdate(runId); + return new Acquisition( + AcquisitionState.ACQUIRED, + new Lease(runId, leaseToken, mode, acquired.upperBoundId()), + acquired + ); + } + + @Transactional + public void recordProgress( + Lease lease, + long nextCursor, + UserProfileBackfillOutcome outcome, + Duration leaseDuration + ) { + Objects.requireNonNull(lease, "lease is required"); + Objects.requireNonNull(outcome, "outcome is required"); + // MySQL CURRENT_TIMESTAMP는 statement 시작 시각이다. 잠금 대기 후 별도 UPDATE에서 만료를 판단한다. + findForUpdate(lease.runId()); + CounterDelta delta = CounterDelta.from(outcome); + int updated = jdbcTemplate.update(""" + UPDATE user_profile_backfill_checkpoint + SET cursor_id = ?, + scanned_count = scanned_count + 1, + prepared_count = prepared_count + ?, + attached_count = attached_count + ?, + skipped_count = skipped_count + ?, + failed_count = failed_count + ?, + lease_until = TIMESTAMPADD(MICROSECOND, ?, CURRENT_TIMESTAMP(6)), + updated_at = CURRENT_TIMESTAMP(6) + WHERE run_id = ? + AND mode = ? + AND status = 'RUNNING' + AND lease_token = ? + AND lease_until > CURRENT_TIMESTAMP(6) + AND cursor_id < ? + AND upper_bound_id >= ? + """, + nextCursor, + delta.prepared(), delta.attached(), delta.skipped(), delta.failed(), + leaseMicros(leaseDuration), + lease.runId().toString(), lease.mode().name(), + lease.token().toString(), nextCursor, nextCursor); + if (updated != 1) { + throw new UserProfileBackfillLeaseLostException(); + } + } + + @Transactional + public Snapshot complete(Lease lease) { + Objects.requireNonNull(lease, "lease is required"); + findForUpdate(lease.runId()); + int updated = jdbcTemplate.update(""" + UPDATE user_profile_backfill_checkpoint + SET status = 'COMPLETED', + cursor_id = upper_bound_id, + lease_token = NULL, + lease_until = NULL, + updated_at = CURRENT_TIMESTAMP(6) + WHERE run_id = ? + AND mode = ? + AND status = 'RUNNING' + AND lease_token = ? + AND lease_until > CURRENT_TIMESTAMP(6) + """, + lease.runId().toString(), lease.mode().name(), + lease.token().toString()); + if (updated != 1) { + throw new UserProfileBackfillLeaseLostException(); + } + return findForUpdate(lease.runId()); + } + + @Transactional + public boolean pause(Lease lease) { + Objects.requireNonNull(lease, "lease is required"); + return jdbcTemplate.update(""" + UPDATE user_profile_backfill_checkpoint + SET status = 'PAUSED', + lease_token = NULL, + lease_until = NULL, + updated_at = CURRENT_TIMESTAMP(6) + WHERE run_id = ? + AND mode = ? + AND status = 'RUNNING' + AND lease_token = ? + """, + lease.runId().toString(), lease.mode().name(), + lease.token().toString()) == 1; + } + + @Transactional(readOnly = true) + public Snapshot find(UUID runId) { + return jdbcTemplate.queryForObject(SELECT_SQL, this::mapSnapshot, runId.toString()); + } + + private Snapshot findForUpdate(UUID runId) { + return jdbcTemplate.queryForObject(SELECT_FOR_UPDATE_SQL, this::mapSnapshot, runId.toString()); + } + + private Snapshot mapSnapshot(ResultSet resultSet, int rowNumber) throws SQLException { + Timestamp leaseUntil = resultSet.getTimestamp("lease_until"); + return new Snapshot( + UUID.fromString(resultSet.getString("run_id")), + UserProfileBackfillMode.valueOf(resultSet.getString("mode")), + Status.valueOf(resultSet.getString("status")), + resultSet.getLong("upper_bound_id"), + resultSet.getLong("cursor_id"), + leaseUntil == null ? null : leaseUntil.toLocalDateTime(), + resultSet.getLong("scanned_count"), + resultSet.getLong("prepared_count"), + resultSet.getLong("attached_count"), + resultSet.getLong("skipped_count"), + resultSet.getLong("failed_count") + ); + } + + private void validateIdentity( + Snapshot snapshot, + UserProfileBackfillMode mode + ) { + if (snapshot.mode() != mode) { + throw new IllegalArgumentException("runId is already assigned to a different backfill execution"); + } + } + + private LocalDateTime databaseNow() { + return jdbcTemplate.queryForObject("SELECT CURRENT_TIMESTAMP(6)", LocalDateTime.class); + } + + private long leaseMicros(Duration duration) { + Objects.requireNonNull(duration, "lease duration is required"); + if (duration.isNegative() || duration.isZero()) { + throw new IllegalArgumentException("lease duration must be positive"); + } + return duration.toNanos() / 1_000L; + } + + private void requirePersistentMode(UserProfileBackfillMode mode) { + if (mode == null || !mode.usesCheckpoint()) { + throw new IllegalArgumentException("only PREPARE and ATTACH use checkpoints"); + } + } + + enum AcquisitionState { + ACQUIRED, + BUSY, + COMPLETED + } + + enum Status { + PAUSED, + RUNNING, + COMPLETED + } + + record Acquisition(AcquisitionState state, Lease lease, Snapshot snapshot) { + + Acquisition { + Objects.requireNonNull(state, "acquisition state is required"); + Objects.requireNonNull(snapshot, "checkpoint snapshot is required"); + if ((state == AcquisitionState.ACQUIRED) != (lease != null)) { + throw new IllegalArgumentException("only acquired checkpoints have a lease"); + } + } + } + + record Lease( + UUID runId, + UUID token, + UserProfileBackfillMode mode, + long upperBoundId + ) { + + Lease { + Objects.requireNonNull(runId, "runId is required"); + Objects.requireNonNull(token, "lease token is required"); + Objects.requireNonNull(mode, "mode is required"); + if (!mode.usesCheckpoint() || upperBoundId < 0) { + throw new IllegalArgumentException("invalid user profile backfill lease"); + } + } + + @Override + public String toString() { + return "UserProfileBackfillLease[redacted]"; + } + } + + record Snapshot( + UUID runId, + UserProfileBackfillMode mode, + Status status, + long upperBoundId, + long cursorId, + LocalDateTime leaseUntil, + long scannedCount, + long preparedCount, + long attachedCount, + long skippedCount, + long failedCount + ) { + + Snapshot { + Objects.requireNonNull(runId, "runId is required"); + Objects.requireNonNull(mode, "mode is required"); + Objects.requireNonNull(status, "status is required"); + } + + @Override + public String toString() { + return "UserProfileBackfillCheckpoint[redacted]"; + } + } + + private record CounterDelta(long prepared, long attached, long skipped, long failed) { + + private static CounterDelta from(UserProfileBackfillOutcome outcome) { + return switch (outcome) { + case PREPARED -> new CounterDelta(1, 0, 0, 0); + case ATTACHED -> new CounterDelta(0, 1, 0, 0); + case SKIPPED -> new CounterDelta(0, 0, 1, 0); + case FAILED -> new CounterDelta(0, 0, 0, 1); + }; + } + } +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillConfiguration.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillConfiguration.java new file mode 100644 index 00000000..a3e2a6a3 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillConfiguration.java @@ -0,0 +1,36 @@ +package org.sopt.hashi.user.migration; + +import java.util.concurrent.Executor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@EnableAsync +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(UserProfileBackfillProperties.class) +public class UserProfileBackfillConfiguration { + + static final String EXECUTOR = "userProfileBackfillExecutor"; + + @Bean(name = EXECUTOR) + @ConditionalOnProperty( + prefix = "hashi.user.profile-backfill", + name = "enabled", + havingValue = "true" + ) + public Executor userProfileBackfillExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(1); + executor.setQueueCapacity(0); + executor.setThreadNamePrefix("user-profile-backfill-"); + // 종료 시 현재 작업을 중단해도 lease와 멱등 prepare를 통해 같은 run-id로 재개한다. + executor.setWaitForTasksToCompleteOnShutdown(false); + executor.setAwaitTerminationSeconds(30); + executor.setStrictEarlyShutdown(true); + return executor; + } +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillLeaseLostException.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillLeaseLostException.java new file mode 100644 index 00000000..e93a79a3 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillLeaseLostException.java @@ -0,0 +1,8 @@ +package org.sopt.hashi.user.migration; + +class UserProfileBackfillLeaseLostException extends RuntimeException { + + UserProfileBackfillLeaseLostException() { + super("user profile backfill lease was lost"); + } +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillMode.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillMode.java new file mode 100644 index 00000000..3712dcbf --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillMode.java @@ -0,0 +1,12 @@ +package org.sopt.hashi.user.migration; + +public enum UserProfileBackfillMode { + + DRY_RUN, + PREPARE, + ATTACH; + + boolean usesCheckpoint() { + return this != DRY_RUN; + } +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillOutcome.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillOutcome.java new file mode 100644 index 00000000..f6cd9a38 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillOutcome.java @@ -0,0 +1,9 @@ +package org.sopt.hashi.user.migration; + +enum UserProfileBackfillOutcome { + + PREPARED, + ATTACHED, + SKIPPED, + FAILED +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillProperties.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillProperties.java new file mode 100644 index 00000000..2c2d4869 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillProperties.java @@ -0,0 +1,78 @@ +package org.sopt.hashi.user.migration; + +import java.time.Duration; +import java.util.Objects; +import java.util.UUID; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "hashi.user.profile-backfill") +public record UserProfileBackfillProperties( + boolean enabled, + String runId, + UserProfileBackfillMode mode, + int batchSize, + int maxBatches, + Duration leaseDuration, + int maxAttempts, + Duration retryInitialDelay +) { + + private static final int MAX_BATCH_SIZE = 500; + private static final int MAX_BATCH_COUNT = 1_000; + private static final int MAX_ATTEMPTS = 5; + private static final Duration MIN_LEASE_DURATION = Duration.ofSeconds(30); + private static final Duration MAX_LEASE_DURATION = Duration.ofMinutes(30); + private static final Duration MAX_RETRY_INITIAL_DELAY = Duration.ofSeconds(10); + + public UserProfileBackfillProperties { + Objects.requireNonNull(mode, "user profile backfill mode is required"); + Objects.requireNonNull(leaseDuration, "user profile backfill lease duration is required"); + Objects.requireNonNull(retryInitialDelay, "user profile backfill retry delay is required"); + requireRange(batchSize, 1, MAX_BATCH_SIZE, "batchSize"); + requireRange(maxBatches, 1, MAX_BATCH_COUNT, "maxBatches"); + requireRange(maxAttempts, 1, MAX_ATTEMPTS, "maxAttempts"); + if (leaseDuration.compareTo(MIN_LEASE_DURATION) < 0 + || leaseDuration.compareTo(MAX_LEASE_DURATION) > 0) { + throw new IllegalArgumentException("leaseDuration must be between 30 seconds and 30 minutes"); + } + if (retryInitialDelay.isNegative() + || retryInitialDelay.compareTo(MAX_RETRY_INITIAL_DELAY) > 0) { + throw new IllegalArgumentException("retryInitialDelay must be between 0 and 10 seconds"); + } + long retryMultiplier = (1L << (maxAttempts - 1)) - 1L; + if (retryInitialDelay.multipliedBy(retryMultiplier).compareTo(leaseDuration) >= 0) { + throw new IllegalArgumentException("retry backoff budget must be shorter than the lease duration"); + } + if (enabled && mode.usesCheckpoint()) { + parseRunId(runId); + } + } + + UUID requiredRunId() { + if (!mode.usesCheckpoint()) { + throw new IllegalStateException("dry-run does not use a persistent run id"); + } + return parseRunId(runId); + } + + private static UUID parseRunId(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("runId is required for PREPARE and ATTACH"); + } + try { + UUID runId = UUID.fromString(value); + if (!runId.toString().equals(value)) { + throw new IllegalArgumentException("runId must be a lowercase canonical UUID"); + } + return runId; + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("runId must be a lowercase canonical UUID", exception); + } + } + + private static void requireRange(int value, int min, int max, String name) { + if (value < min || value > max) { + throw new IllegalArgumentException("%s must be between %d and %d".formatted(name, min, max)); + } + } +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java new file mode 100644 index 00000000..805e9c57 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java @@ -0,0 +1,317 @@ +package org.sopt.hashi.user.migration; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; +import org.sopt.hashi.media.MediaBackfillAssetInfo; +import org.sopt.hashi.media.MediaBackfillInspectionInfo; +import org.sopt.hashi.media.MediaBackfillPort; +import org.sopt.hashi.media.MediaBackfillReference; +import org.sopt.hashi.media.MediaBackfillSourceException; +import org.sopt.hashi.media.MediaBackfillTarget; +import org.sopt.hashi.media.MediaBackfillSourceException.Reason; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Acquisition; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.AcquisitionState; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Lease; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Snapshot; +import org.sopt.hashi.user.migration.UserProfileBackfillSummary.Status; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty( + prefix = "hashi.user.profile-backfill", + name = "enabled", + havingValue = "true" +) +class UserProfileBackfillRunner { + + private final UserProfileBackfillProperties properties; + private final UserProfileBackfillCandidateReader candidateReader; + private final UserProfileBackfillCheckpointStore checkpointStore; + private final UserProfileBackfillAttachmentService attachmentService; + private final MediaBackfillPort mediaBackfillPort; + + UserProfileBackfillRunner( + UserProfileBackfillProperties properties, + UserProfileBackfillCandidateReader candidateReader, + UserProfileBackfillCheckpointStore checkpointStore, + UserProfileBackfillAttachmentService attachmentService, + MediaBackfillPort mediaBackfillPort + ) { + this.properties = properties; + this.candidateReader = candidateReader; + this.checkpointStore = checkpointStore; + this.attachmentService = attachmentService; + this.mediaBackfillPort = mediaBackfillPort; + } + + @Async(UserProfileBackfillConfiguration.EXECUTOR) + @EventListener(ApplicationReadyEvent.class) + public void runOnStartup() { + try { + UserProfileBackfillSummary summary = execute(); + log.info( + "User profile backfill finished: mode={}, status={}, " + + "scanned={}, inspected={}, prepared={}, attached={}, skipped={}, failed={}", + summary.mode(), summary.status(), summary.scannedCount(), + summary.inspectedCount(), summary.preparedCount(), summary.attachedCount(), + summary.skippedCount(), summary.failedCount() + ); + } catch (RuntimeException exception) { + log.error( + "User profile backfill could not start: mode={}, errorType={}", + properties.mode(), exception.getClass().getSimpleName() + ); + } + } + + UserProfileBackfillSummary execute() { + if (properties.mode() == UserProfileBackfillMode.DRY_RUN) { + return executeDryRun(); + } + return executePersistent(); + } + + private UserProfileBackfillSummary executeDryRun() { + long upperBound = candidateReader.findUpperBound(); + long cursor = 0L; + MutableSummary summary = new MutableSummary(properties.mode()); + + for (int batchNumber = 0; batchNumber < properties.maxBatches(); batchNumber++) { + requireNotInterrupted(); + List candidates = findBatch(cursor, upperBound); + if (candidates.isEmpty()) { + return summary.finish(Status.COMPLETED); + } + for (UserProfileBackfillCandidate candidate : candidates) { + requireNotInterrupted(); + summary.scanned++; + if (!candidate.hasUsableLegacyKey()) { + summary.failed++; + cursor = candidate.userId(); + continue; + } + try { + inspect(candidate); + summary.inspected++; + } catch (MediaBackfillSourceException exception) { + summary.failed++; + } + cursor = candidate.userId(); + } + if (candidates.size() < properties.batchSize()) { + return summary.finish(Status.COMPLETED); + } + } + Status status = hasMore(cursor, upperBound) ? Status.PAUSED : Status.COMPLETED; + return summary.finish(status); + } + + private UserProfileBackfillSummary executePersistent() { + long initialUpperBound = candidateReader.findUpperBound(); + Acquisition acquisition = checkpointStore.acquire( + properties.requiredRunId(), properties.mode(), + initialUpperBound, properties.leaseDuration() + ); + if (acquisition.state() == AcquisitionState.BUSY) { + return UserProfileBackfillSummary.fromSnapshot(Status.BUSY, acquisition.snapshot()); + } + if (acquisition.state() == AcquisitionState.COMPLETED) { + return UserProfileBackfillSummary.fromSnapshot( + Status.ALREADY_COMPLETED, acquisition.snapshot()); + } + + Lease lease = acquisition.lease(); + long cursor = acquisition.snapshot().cursorId(); + try { + for (int batchNumber = 0; batchNumber < properties.maxBatches(); batchNumber++) { + requireNotInterrupted(); + List candidates = findBatch(cursor, lease.upperBoundId()); + if (candidates.isEmpty()) { + return completedSummary(lease); + } + for (UserProfileBackfillCandidate candidate : candidates) { + requireNotInterrupted(); + processAndRecord(candidate, lease); + cursor = candidate.userId(); + } + if (candidates.size() < properties.batchSize()) { + return completedSummary(lease); + } + } + if (!hasMore(cursor, lease.upperBoundId())) { + return completedSummary(lease); + } + checkpointStore.pause(lease); + return UserProfileBackfillSummary.fromSnapshot( + Status.PAUSED, checkpointStore.find(lease.runId())); + } catch (UserProfileBackfillLeaseLostException exception) { + return UserProfileBackfillSummary.fromSnapshot( + Status.LEASE_LOST, checkpointStore.find(lease.runId())); + } catch (RuntimeException exception) { + boolean paused = checkpointStore.pause(lease); + log.error( + "User profile backfill stopped: mode={}, errorType={}", + properties.mode(), exception.getClass().getSimpleName() + ); + return UserProfileBackfillSummary.fromSnapshot( + paused ? Status.FAILED : Status.LEASE_LOST, + checkpointStore.find(lease.runId())); + } + } + + private void processAndRecord(UserProfileBackfillCandidate candidate, Lease lease) { + if (!candidate.hasUsableLegacyKey()) { + checkpointStore.recordProgress( + lease, candidate.userId(), + UserProfileBackfillOutcome.FAILED, properties.leaseDuration()); + return; + } + try { + MediaBackfillInspectionInfo inspection = inspect(candidate); + if (properties.mode() == UserProfileBackfillMode.PREPARE) { + UserProfileBackfillOutcome outcome = prepare(candidate, inspection); + checkpointStore.recordProgress( + lease, candidate.userId(), outcome, properties.leaseDuration()); + return; + } + attachOrRecord(candidate, inspection, lease); + } catch (MediaBackfillSourceException exception) { + checkpointStore.recordProgress( + lease, candidate.userId(), + UserProfileBackfillOutcome.FAILED, properties.leaseDuration()); + } + } + + private MediaBackfillInspectionInfo inspect(UserProfileBackfillCandidate candidate) { + MediaBackfillReference reference = reference(candidate); + return withStorageRetry(() -> mediaBackfillPort.inspect(reference)); + } + + private UserProfileBackfillOutcome prepare( + UserProfileBackfillCandidate candidate, + MediaBackfillInspectionInfo inspection + ) { + Optional existing = inspection.asset(); + if (existing.isPresent() && existing.get().state() != MediaBackfillAssetInfo.State.PENDING_COPY) { + return preparedState(existing.get().state()); + } + MediaBackfillAssetInfo prepared = withStorageRetry(() -> mediaBackfillPort.prepare( + reference(candidate), inspection.identityHash())); + return preparedState(prepared.state()); + } + + private UserProfileBackfillOutcome preparedState(MediaBackfillAssetInfo.State state) { + return switch (state) { + case PENDING_COPY, PROCESSING, READY -> UserProfileBackfillOutcome.PREPARED; + case FAILED, EXPIRED, BOUND, RETIRED, PURGING, PURGED -> + UserProfileBackfillOutcome.FAILED; + }; + } + + private void attachOrRecord( + UserProfileBackfillCandidate candidate, + MediaBackfillInspectionInfo inspection, + Lease lease + ) { + Optional asset = inspection.asset(); + if (asset.isPresent() && asset.get().state() == MediaBackfillAssetInfo.State.READY) { + attachmentService.attachAndRecord( + candidate, asset.get(), lease, properties.leaseDuration()); + return; + } + UserProfileBackfillOutcome outcome = asset.isPresent() + && terminal(asset.get().state()) + ? UserProfileBackfillOutcome.FAILED + : UserProfileBackfillOutcome.SKIPPED; + checkpointStore.recordProgress( + lease, candidate.userId(), outcome, properties.leaseDuration()); + } + + private boolean terminal(MediaBackfillAssetInfo.State state) { + return switch (state) { + case FAILED, EXPIRED, BOUND, RETIRED, PURGING, PURGED -> true; + case PENDING_COPY, PROCESSING, READY -> false; + }; + } + + private T withStorageRetry(Supplier operation) { + int attempt = 1; + while (true) { + try { + return operation.get(); + } catch (MediaBackfillSourceException exception) { + boolean retryable = exception.getReason() == Reason.STORAGE_UNAVAILABLE; + if (!retryable || attempt >= properties.maxAttempts()) { + throw exception; + } + sleep(backoff(properties.retryInitialDelay(), attempt)); + attempt++; + } + } + } + + private Duration backoff(Duration initialDelay, int attempt) { + return initialDelay.multipliedBy(1L << (attempt - 1)); + } + + private void sleep(Duration delay) { + try { + Thread.sleep(delay); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("user profile backfill was interrupted"); + } + } + + private void requireNotInterrupted() { + if (Thread.currentThread().isInterrupted()) { + throw new IllegalStateException("user profile backfill was interrupted"); + } + } + + private MediaBackfillReference reference(UserProfileBackfillCandidate candidate) { + return new MediaBackfillReference( + MediaBackfillTarget.USER_PROFILE, candidate.userId(), candidate.legacyKey()); + } + + private List findBatch(long cursor, long upperBound) { + return candidateReader.findBatch( + cursor, upperBound, properties.batchSize()); + } + + private boolean hasMore(long cursor, long upperBound) { + return !candidateReader.findBatch(cursor, upperBound, 1).isEmpty(); + } + + private UserProfileBackfillSummary completedSummary(Lease lease) { + Snapshot snapshot = checkpointStore.complete(lease); + return UserProfileBackfillSummary.fromSnapshot(Status.COMPLETED, snapshot); + } + + private static final class MutableSummary { + + private final UserProfileBackfillMode mode; + private long scanned; + private long inspected; + private long failed; + + private MutableSummary( + UserProfileBackfillMode mode + ) { + this.mode = mode; + } + + private UserProfileBackfillSummary finish(Status status) { + return new UserProfileBackfillSummary( + mode, status, scanned, inspected, 0L, 0L, 0L, failed); + } + } +} diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillSummary.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillSummary.java new file mode 100644 index 00000000..2bcdd675 --- /dev/null +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillSummary.java @@ -0,0 +1,38 @@ +package org.sopt.hashi.user.migration; + +import java.util.Objects; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Snapshot; + +record UserProfileBackfillSummary( + UserProfileBackfillMode mode, + Status status, + long scannedCount, + long inspectedCount, + long preparedCount, + long attachedCount, + long skippedCount, + long failedCount +) { + + UserProfileBackfillSummary { + Objects.requireNonNull(mode, "mode is required"); + Objects.requireNonNull(status, "status is required"); + } + + static UserProfileBackfillSummary fromSnapshot(Status status, Snapshot snapshot) { + return new UserProfileBackfillSummary( + snapshot.mode(), status, + snapshot.scannedCount(), 0L, snapshot.preparedCount(), + snapshot.attachedCount(), snapshot.skippedCount(), snapshot.failedCount() + ); + } + + enum Status { + COMPLETED, + PAUSED, + BUSY, + ALREADY_COMPLETED, + FAILED, + LEASE_LOST + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c6d334da..d701a4e7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -89,6 +89,17 @@ hashi: presigned-url-expiration: 300s max-file-size: 5MB max-files-per-request: 10 + user: + profile-backfill: + # 활성 회원의 프로필만 처리하는 승인된 one-shot 실행에서 별도로 켠다. + enabled: ${USER_PROFILE_BACKFILL_ENABLED:false} + run-id: ${USER_PROFILE_BACKFILL_RUN_ID:} + mode: ${USER_PROFILE_BACKFILL_MODE:DRY_RUN} + batch-size: ${USER_PROFILE_BACKFILL_BATCH_SIZE:50} + max-batches: ${USER_PROFILE_BACKFILL_MAX_BATCHES:10} + lease-duration: ${USER_PROFILE_BACKFILL_LEASE_DURATION:5m} + max-attempts: ${USER_PROFILE_BACKFILL_MAX_ATTEMPTS:3} + retry-initial-delay: ${USER_PROFILE_BACKFILL_RETRY_INITIAL_DELAY:200ms} restaurant: media-backfill: # 승인된 one-shot 실행에서만 켠다. PREPARE/ATTACH는 재시작 가능한 고유 run-id가 필수다. diff --git a/src/main/resources/db/migration/V23__create_user_profile_backfill_checkpoint.sql b/src/main/resources/db/migration/V23__create_user_profile_backfill_checkpoint.sql new file mode 100644 index 00000000..63b5f8a7 --- /dev/null +++ b/src/main/resources/db/migration/V23__create_user_profile_backfill_checkpoint.sql @@ -0,0 +1,36 @@ +CREATE TABLE user_profile_backfill_checkpoint ( + run_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + mode VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + upper_bound_id BIGINT NOT NULL, + cursor_id BIGINT NOT NULL DEFAULT 0, + lease_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + lease_until DATETIME(6) NULL, + scanned_count BIGINT NOT NULL DEFAULT 0, + prepared_count BIGINT NOT NULL DEFAULT 0, + attached_count BIGINT NOT NULL DEFAULT 0, + skipped_count BIGINT NOT NULL DEFAULT 0, + failed_count BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (run_id), + CONSTRAINT ck_user_profile_backfill_run_id CHECK ( + run_id REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + ), + CONSTRAINT ck_user_profile_backfill_mode CHECK (mode IN ('PREPARE', 'ATTACH')), + CONSTRAINT ck_user_profile_backfill_status CHECK (status IN ('PAUSED', 'RUNNING', 'COMPLETED')), + CONSTRAINT ck_user_profile_backfill_cursor CHECK ( + upper_bound_id >= 0 AND cursor_id >= 0 AND cursor_id <= upper_bound_id + ), + CONSTRAINT ck_user_profile_backfill_counts CHECK ( + scanned_count >= 0 + AND prepared_count >= 0 AND attached_count >= 0 + AND skipped_count >= 0 AND failed_count >= 0 + AND prepared_count + attached_count + skipped_count + failed_count = scanned_count + ), + CONSTRAINT ck_user_profile_backfill_lease CHECK ( + (status = 'RUNNING' AND lease_token IS NOT NULL AND lease_until IS NOT NULL + AND lease_token REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') + OR (status IN ('PAUSED', 'COMPLETED') AND lease_token IS NULL AND lease_until IS NULL) + ) +); diff --git a/src/test/java/org/sopt/hashi/user/domain/UserProfileImageTest.java b/src/test/java/org/sopt/hashi/user/domain/UserProfileImageTest.java index 0b01c278..784c9ccd 100644 --- a/src/test/java/org/sopt/hashi/user/domain/UserProfileImageTest.java +++ b/src/test/java/org/sopt/hashi/user/domain/UserProfileImageTest.java @@ -6,6 +6,7 @@ import java.time.LocalDate; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; class UserProfileImageTest { @@ -45,6 +46,57 @@ class UserProfileImageTest { assertThat(user.getProfileImageAssetId()).isNull(); } + @Test + void backfill은_legacy_key와_회원_정보를_그대로_두고_asset만_연결한다() { + User user = user("profiles/legacy.jpg", null); + UUID assetId = UUID.randomUUID(); + + assertThat(user.attachBackfilledProfileImage("profiles/legacy.jpg", assetId)).isTrue(); + + assertThat(user.getProfileImageAssetId()).isEqualTo(assetId); + assertThat(user.getProfileImageKey()).isEqualTo("profiles/legacy.jpg"); + assertThat(user.getNickname()).isEqualTo("프로필회원"); + assertThat(user.getNameEng()).isEqualTo("HASHI"); + assertThat(user.getBirthDate()).isEqualTo(LocalDate.of(1998, 1, 1)); + assertThat(user.getPhone()).isEqualTo("01000000001"); + assertThat(user.getEmail()).isEqualTo("profile@hashi.test"); + assertThat(user.isDeleted()).isFalse(); + } + + @Test + void backfill은_변경된_source_기본_프로필_기존_asset과_탈퇴_상태를_덮어쓰지_않는다() { + User changed = user("profiles/new.jpg", null); + User defaultProfile = user(null, null); + UUID originalAssetId = UUID.randomUUID(); + User assetProfile = user(null, originalAssetId); + User deleted = user("profiles/old.jpg", null); + ReflectionTestUtils.setField(deleted, "deleted", true); + + for (User candidate : new User[]{changed, defaultProfile, assetProfile, deleted}) { + assertThat(candidate.attachBackfilledProfileImage("profiles/old.jpg", UUID.randomUUID())) + .isFalse(); + } + + assertThat(changed.getProfileImageKey()).isEqualTo("profiles/new.jpg"); + assertThat(changed.getProfileImageAssetId()).isNull(); + assertThat(defaultProfile.getProfileImageAssetId()).isNull(); + assertThat(assetProfile.getProfileImageAssetId()).isEqualTo(originalAssetId); + assertThat(deleted.isDeleted()).isTrue(); + assertThat(deleted.getProfileImageAssetId()).isNull(); + } + + @Test + void backfill은_한번_연결된_asset이나_null_asset으로_교체하지_않는다() { + User user = user("profiles/legacy.jpg", null); + UUID assetId = UUID.randomUUID(); + user.attachBackfilledProfileImage("profiles/legacy.jpg", assetId); + + assertThat(user.attachBackfilledProfileImage("profiles/legacy.jpg", UUID.randomUUID())).isFalse(); + assertThatThrownBy(() -> user.attachBackfilledProfileImage("profiles/legacy.jpg", null)) + .isInstanceOf(NullPointerException.class); + assertThat(user.getProfileImageAssetId()).isEqualTo(assetId); + } + private User user(String key, UUID assetId) { return User.onboard( "프로필회원", "HASHI", LocalDate.of(1998, 1, 1), diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillAttachmentServiceTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillAttachmentServiceTest.java new file mode 100644 index 00000000..86006ad4 --- /dev/null +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillAttachmentServiceTest.java @@ -0,0 +1,110 @@ +package org.sopt.hashi.user.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.media.MediaAssetPurpose; +import org.sopt.hashi.media.MediaBackfillAssetInfo; +import org.sopt.hashi.media.MediaBackfillPort; +import org.sopt.hashi.user.domain.User; +import org.sopt.hashi.user.domain.UserRepository; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Lease; + +class UserProfileBackfillAttachmentServiceTest { + + private static final String IDENTITY = "b".repeat(64); + private static final Duration DURATION = Duration.ofMinutes(5); + private final UserRepository userRepository = mock(UserRepository.class); + private final MediaBackfillPort mediaBackfillPort = mock(MediaBackfillPort.class); + private final UserProfileBackfillCheckpointStore checkpointStore = + mock(UserProfileBackfillCheckpointStore.class); + private final UserProfileBackfillAttachmentService service = new UserProfileBackfillAttachmentService( + userRepository, mediaBackfillPort, checkpointStore); + + @Test + void 잠근_프로필이_그대로면_claim과_cursor를_같이_기록한다() { + User user = user("profiles/legacy.jpg"); + UserProfileBackfillCandidate candidate = new UserProfileBackfillCandidate(11L, "profiles/legacy.jpg"); + MediaBackfillAssetInfo asset = readyAsset(); + Lease lease = lease(); + given(userRepository.findByIdForUpdate(11L)).willReturn(Optional.of(user)); + + assertThat(service.attachAndRecord(candidate, asset, lease, DURATION)) + .isEqualTo(UserProfileBackfillOutcome.ATTACHED); + + assertThat(user.getProfileImageKey()).isEqualTo("profiles/legacy.jpg"); + assertThat(user.getProfileImageAssetId()).isEqualTo(asset.assetId()); + verify(mediaBackfillPort).claimReady(argThat(claims -> claims.size() == 1 + && claims.iterator().next().assetId().equals(asset.assetId()) + && claims.iterator().next().identityHash().equals(IDENTITY) + && claims.iterator().next().purpose() == MediaAssetPurpose.PROFILE)); + verify(checkpointStore).recordProgress(lease, 11L, UserProfileBackfillOutcome.ATTACHED, DURATION); + } + + @Test + void 잠금_조회에서_탈퇴나_삭제된_회원이_제외되면_claim하지_않는다() { + Lease lease = lease(); + given(userRepository.findByIdForUpdate(11L)).willReturn(Optional.empty()); + + assertThat(service.attachAndRecord( + new UserProfileBackfillCandidate(11L, "profiles/legacy.jpg"), readyAsset(), lease, DURATION)) + .isEqualTo(UserProfileBackfillOutcome.SKIPPED); + + verify(mediaBackfillPort, never()).claimReady(any()); + verify(checkpointStore).recordProgress(lease, 11L, UserProfileBackfillOutcome.SKIPPED, DURATION); + } + + @Test + void 잠금_대기_중_source가_변경되면_현재_프로필을_보존한다() { + User user = user("profiles/changed.jpg"); + given(userRepository.findByIdForUpdate(11L)).willReturn(Optional.of(user)); + + assertThat(service.attachAndRecord(new UserProfileBackfillCandidate(11L, "profiles/old.jpg"), + readyAsset(), lease(), DURATION)).isEqualTo(UserProfileBackfillOutcome.SKIPPED); + + assertThat(user.getProfileImageKey()).isEqualTo("profiles/changed.jpg"); + assertThat(user.getProfileImageAssetId()).isNull(); + verify(mediaBackfillPort, never()).claimReady(any()); + } + + @Test + void PROFILE이_아니거나_READY가_아닌_asset은_DB_접근_전에_거부한다() { + UserProfileBackfillCandidate candidate = new UserProfileBackfillCandidate(11L, "profiles/legacy.jpg"); + MediaBackfillAssetInfo wrongPurpose = new MediaBackfillAssetInfo( + UUID.randomUUID(), MediaAssetPurpose.RESTAURANT, IDENTITY, MediaBackfillAssetInfo.State.READY); + MediaBackfillAssetInfo processing = new MediaBackfillAssetInfo( + UUID.randomUUID(), MediaAssetPurpose.PROFILE, IDENTITY, MediaBackfillAssetInfo.State.PROCESSING); + + assertThatThrownBy(() -> service.attachAndRecord(candidate, wrongPurpose, lease(), DURATION)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.attachAndRecord(candidate, processing, lease(), DURATION)) + .isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(userRepository, mediaBackfillPort, checkpointStore); + } + + private MediaBackfillAssetInfo readyAsset() { + return new MediaBackfillAssetInfo(UUID.randomUUID(), MediaAssetPurpose.PROFILE, + IDENTITY, MediaBackfillAssetInfo.State.READY); + } + + private Lease lease() { + return new Lease(UUID.randomUUID(), UUID.randomUUID(), UserProfileBackfillMode.ATTACH, 20L); + } + + private User user(String key) { + return User.onboard("프로필회원", "HASHI", LocalDate.of(1998, 1, 1), + "01000000001", "profile@hashi.test", key); + } +} diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillLifecycleTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillLifecycleTest.java new file mode 100644 index 00000000..88796f7d --- /dev/null +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillLifecycleTest.java @@ -0,0 +1,95 @@ +package org.sopt.hashi.user.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.media.MediaBackfillPort; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.support.DefaultLifecycleProcessor; + +class UserProfileBackfillLifecycleTest { + + private final UserProfileBackfillCandidateReader reader = mock(UserProfileBackfillCandidateReader.class); + private final UserProfileBackfillCheckpointStore checkpoint = mock(UserProfileBackfillCheckpointStore.class); + private final UserProfileBackfillAttachmentService attachment = mock(UserProfileBackfillAttachmentService.class); + private final MediaBackfillPort port = mock(MediaBackfillPort.class); + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(UserProfileBackfillConfiguration.class, UserProfileBackfillRunner.class) + .withBean(UserProfileBackfillCandidateReader.class, () -> reader) + .withBean(UserProfileBackfillCheckpointStore.class, () -> checkpoint) + .withBean(UserProfileBackfillAttachmentService.class, () -> attachment) + .withBean(MediaBackfillPort.class, () -> port) + .withPropertyValues( + "hashi.user.profile-backfill.mode=DRY_RUN", + "hashi.user.profile-backfill.batch-size=50", + "hashi.user.profile-backfill.max-batches=10", + "hashi.user.profile-backfill.lease-duration=5m", + "hashi.user.profile-backfill.max-attempts=3", + "hashi.user.profile-backfill.retry-initial-delay=200ms"); + + @Test + void 기본_비활성_상태에서는_runner와_executor가_없고_시작_이벤트로_실행되지_않는다() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed().doesNotHaveBean(UserProfileBackfillRunner.class); + assertThat(context).doesNotHaveBean(UserProfileBackfillConfiguration.EXECUTOR); + context.publishEvent(new ApplicationReadyEvent( + new SpringApplication(), new String[0], context.getSourceApplicationContext(), Duration.ZERO)); + verifyNoInteractions(reader, checkpoint, attachment, port); + }); + } + + @Test + void 시작_이벤트는_전용_executor를_사용하고_context_종료는_진행_중인_작업을_중단한다() { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + AtomicReference workerThread = new AtomicReference<>(); + given(reader.findUpperBound()).willReturn(1L); + given(reader.findBatch(0L, 1L, 50)) + .willReturn(List.of(new UserProfileBackfillCandidate(1L, "profiles/lifecycle.jpg"))); + given(port.inspect(any())).willAnswer(invocation -> { + workerThread.set(Thread.currentThread().getName()); + entered.countDown(); + try { + if (!new CountDownLatch(1).await(10, TimeUnit.SECONDS)) { + throw new AssertionError("worker was not interrupted before the test bound"); + } + throw new AssertionError("unreachable latch completion"); + } catch (InterruptedException exception) { + interrupted.countDown(); + Thread.currentThread().interrupt(); + throw new IllegalStateException("test worker was interrupted"); + } + }); + + contextRunner.withPropertyValues("hashi.user.profile-backfill.enabled=true") + .withBean("lifecycleProcessor", DefaultLifecycleProcessor.class, () -> { + DefaultLifecycleProcessor processor = new DefaultLifecycleProcessor(); + // 테스트에서는 graceful-stop 대기만 단축하며 실제 executor의 interrupt 설정은 유지한다. + processor.setTimeoutPerShutdownPhase(100L); + return processor; + }) + .run(context -> { + assertThat(context).hasNotFailed(); + context.publishEvent(new ApplicationReadyEvent( + new SpringApplication(), new String[0], context.getSourceApplicationContext(), Duration.ZERO)); + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(workerThread.get()).startsWith("user-profile-backfill-"); + + context.getSourceApplicationContext().close(); + + assertThat(interrupted.await(2, TimeUnit.SECONDS)).isTrue(); + verifyNoInteractions(checkpoint, attachment); + }); + } +} diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java new file mode 100644 index 00000000..29482e37 --- /dev/null +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java @@ -0,0 +1,548 @@ +package org.sopt.hashi.user.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +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.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.media.MediaAssetPurpose; +import org.sopt.hashi.media.MediaBackfillAssetInfo; +import org.sopt.hashi.media.domain.ImageAsset; +import org.sopt.hashi.media.domain.ImageAssetRepository; +import org.sopt.hashi.media.domain.ImageBindingStatus; +import org.sopt.hashi.media.domain.MediaPurpose; +import org.sopt.hashi.media.internal.backfill.MediaBackfillStorage; +import org.sopt.hashi.user.domain.User; +import org.sopt.hashi.user.domain.UserRepository; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Acquisition; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.AcquisitionState; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Lease; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Snapshot; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** User + 실제 media claim + checkpoint의 원자성을 검증하는 교차 모듈 MySQL 통합 gate. */ +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest(properties = { + "spring.jpa.hibernate.ddl-auto=validate", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "kakao.client-id=test-client-id", + "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test", + "hashi.media.backfill.enabled=true", + "hashi.user.profile-backfill.enabled=false" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class UserProfileBackfillPersistenceIntegrationTest { + + private static final Duration LEASE_DURATION = Duration.ofMinutes(5); + private static final String SPEC_DIGEST = + "1b5759a9285732133699114e21101b3b9b43b5cd8e208bf1246d059f4293634f"; + private static final String SOURCE_CHECKSUM = + "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + + @Container + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi") + .withUsername("hashi") + .withPassword("hashi"); + + @Autowired + private UserProfileBackfillCheckpointStore checkpointStore; + @Autowired + private UserProfileBackfillCandidateReader candidateReader; + @Autowired + private UserProfileBackfillAttachmentService attachmentService; + @Autowired + private UserRepository userRepository; + @Autowired + private ImageAssetRepository imageAssetRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private TransactionTemplate transactionTemplate; + @Autowired + @Qualifier("japanClock") + private Clock clock; + @MockitoBean + private MediaBackfillStorage mediaBackfillStorage; + + @BeforeEach + void setUp() { + jdbcTemplate.update("DELETE FROM user_profile_backfill_checkpoint"); + jdbcTemplate.update("DELETE FROM users"); + jdbcTemplate.update("DELETE FROM image_rendition"); + jdbcTemplate.update("DELETE FROM image_asset"); + } + + @Test + void 같은_run_ID는_상한과_cursor를_보존하며_한_worker만_lease를_얻는다() { + UUID runId = UUID.randomUUID(); + Acquisition first = checkpointStore.acquire( + runId, UserProfileBackfillMode.PREPARE, 100L, LEASE_DURATION); + Acquisition busy = checkpointStore.acquire( + runId, UserProfileBackfillMode.PREPARE, 200L, LEASE_DURATION); + + assertThat(first.state()).isEqualTo(AcquisitionState.ACQUIRED); + assertThat(busy.state()).isEqualTo(AcquisitionState.BUSY); + checkpointStore.recordProgress( + first.lease(), 10L, UserProfileBackfillOutcome.PREPARED, LEASE_DURATION); + assertThat(checkpointStore.pause(first.lease())).isTrue(); + + Acquisition resumed = checkpointStore.acquire( + runId, UserProfileBackfillMode.PREPARE, 200L, LEASE_DURATION); + + assertThat(resumed.state()).isEqualTo(AcquisitionState.ACQUIRED); + assertThat(resumed.snapshot().upperBoundId()).isEqualTo(100L); + assertThat(resumed.snapshot().cursorId()).isEqualTo(10L); + assertThat(resumed.snapshot().preparedCount()).isEqualTo(1L); + assertThat(resumed.lease().token()).isNotEqualTo(first.lease().token()); + } + + @Test + void 만료된_lease의_worker는_새_worker의_cursor를_갱신하거나_중지할_수_없다() { + UUID runId = UUID.randomUUID(); + Lease first = acquire(runId, 100L); + expire(first); + Lease replacement = acquire(runId, 100L); + + assertThatThrownBy(() -> checkpointStore.recordProgress( + first, 10L, UserProfileBackfillOutcome.ATTACHED, LEASE_DURATION)) + .isInstanceOf(UserProfileBackfillLeaseLostException.class); + assertThat(checkpointStore.pause(first)).isFalse(); + checkpointStore.recordProgress( + replacement, 20L, UserProfileBackfillOutcome.SKIPPED, LEASE_DURATION); + + Snapshot snapshot = checkpointStore.find(runId); + assertThat(snapshot.cursorId()).isEqualTo(20L); + assertThat(snapshot.scannedCount()).isEqualTo(1L); + assertThat(snapshot.skippedCount()).isEqualTo(1L); + assertThat(snapshot.attachedCount()).isZero(); + } + + @Test + void checkpoint_잠금_대기_중_만료된_lease는_갱신하지_않는다() throws Exception { + UUID runId = UUID.randomUUID(); + Lease lease = checkpointStore.acquire( + runId, UserProfileBackfillMode.ATTACH, 10L, Duration.ofSeconds(3)).lease(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try (Connection connection = DriverManager.getConnection( + MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword())) { + connection.setAutoCommit(false); + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT run_id FROM user_profile_backfill_checkpoint WHERE run_id = ? FOR UPDATE + """)) { + statement.setString(1, runId.toString()); + try (ResultSet ignored = statement.executeQuery()) { + assertThat(ignored.next()).isTrue(); + } + } + Future progress = executor.submit(() -> checkpointStore.recordProgress( + lease, 1L, UserProfileBackfillOutcome.SKIPPED, LEASE_DURATION)); + try { + awaitTableLockWait("user_profile_backfill_checkpoint"); + awaitLeaseExpiry(runId); + } finally { + connection.commit(); + } + + assertThatThrownBy(() -> progress.get(10, TimeUnit.SECONDS)) + .hasCauseInstanceOf(UserProfileBackfillLeaseLostException.class); + } finally { + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } + + assertThat(checkpointStore.find(runId).scannedCount()).isZero(); + assertThat(checkpointStore.find(runId).cursorId()).isZero(); + } + + @Test + void 같은_run_ID의_mode는_변경하지_않고_거부_후에도_기록을_보존한다() { + UUID runId = UUID.randomUUID(); + Lease lease = acquire(runId, 10L); + checkpointStore.pause(lease); + Snapshot before = checkpointStore.find(runId); + + assertThatThrownBy(() -> checkpointStore.acquire( + runId, UserProfileBackfillMode.PREPARE, 10L, LEASE_DURATION)) + .isInstanceOf(InvalidDataAccessApiUsageException.class) + .hasRootCauseExactlyInstanceOf(IllegalArgumentException.class) + .hasRootCauseMessage("runId is already assigned to a different backfill execution"); + assertThat(checkpointStore.find(runId)).isEqualTo(before); + } + + @Test + void 완료된_run_ID는_새_상한이_생겨도_다시_실행하지_않는다() { + UUID runId = UUID.randomUUID(); + Lease lease = acquire(runId, 10L); + checkpointStore.complete(lease); + + Acquisition completed = checkpointStore.acquire( + runId, UserProfileBackfillMode.ATTACH, 20L, LEASE_DURATION); + + assertThat(completed.state()).isEqualTo(AcquisitionState.COMPLETED); + assertThat(completed.snapshot().upperBoundId()).isEqualTo(10L); + assertThat(completed.snapshot().cursorId()).isEqualTo(10L); + } + + @Test + void V23은_잘못된_mode_cursor_lease와_집계값을_DB에서도_거부한다() { + UUID runId = UUID.randomUUID(); + Lease lease = acquire(runId, 10L); + + assertThatThrownBy(() -> jdbcTemplate.update( + "UPDATE user_profile_backfill_checkpoint SET mode = 'DRY_RUN' WHERE run_id = ?", + runId.toString())).isInstanceOf(DataAccessException.class); + assertThatThrownBy(() -> jdbcTemplate.update( + "UPDATE user_profile_backfill_checkpoint SET cursor_id = 11 WHERE run_id = ?", + runId.toString())).isInstanceOf(DataAccessException.class); + assertThatThrownBy(() -> jdbcTemplate.update( + "UPDATE user_profile_backfill_checkpoint SET lease_token = NULL WHERE run_id = ?", + runId.toString())).isInstanceOf(DataAccessException.class); + assertThatThrownBy(() -> jdbcTemplate.update( + "UPDATE user_profile_backfill_checkpoint SET attached_count = 1 WHERE run_id = ?", + runId.toString())).isInstanceOf(DataAccessException.class); + assertThat(checkpointStore.pause(lease)).isTrue(); + assertThat(jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema = DATABASE() + AND table_name = 'user_profile_backfill_checkpoint' + AND referenced_table_name IS NOT NULL + """, Integer.class)).isZero(); + } + + @Test + void keyset은_활성_legacy_프로필만_읽고_고정_상한_이후_신규_회원은_제외한다() { + User first = saveUser("profiles/first.jpg"); + User deleted = saveUser("profiles/deleted.jpg"); + userRepository.deleteById(deleted.getId()); + saveUser(null); + User alreadyMigrated = saveUser("profiles/migrated.jpg"); + transactionTemplate.executeWithoutResult(status -> { + User locked = userRepository.findByIdForUpdate(alreadyMigrated.getId()).orElseThrow(); + locked.attachBackfilledProfileImage(locked.getProfileImageKey(), UUID.randomUUID()); + }); + User last = saveUser("profiles/last.jpg"); + long upper = candidateReader.findUpperBound(); + saveUser("profiles/new.jpg"); + + List firstPage = candidateReader.findBatch(0L, upper, 1); + List secondPage = candidateReader.findBatch( + firstPage.getFirst().userId(), upper, 10); + + assertThat(upper).isEqualTo(last.getId()); + assertThat(firstPage).extracting(UserProfileBackfillCandidate::userId).containsExactly(first.getId()); + assertThat(secondPage).extracting(UserProfileBackfillCandidate::userId).containsExactly(last.getId()); + assertThat(userRepository.findById(deleted.getId())).isEmpty(); + } + + @Test + void READY_프로필은_회원_정보와_legacy_key를_보존하며_claim과_cursor를_같이_commit한다() { + User user = saveUser("profiles/legacy.jpg"); + Map before = userDetails(user.getId()); + ImageAsset asset = readyAsset(); + Lease lease = acquire(UUID.randomUUID(), user.getId()); + + assertThat(attachmentService.attachAndRecord(candidate(user), info(asset), lease, LEASE_DURATION)) + .isEqualTo(UserProfileBackfillOutcome.ATTACHED); + + assertThat(userDetails(user.getId())).isEqualTo(before); + assertThat(userRepository.findById(user.getId()).orElseThrow().getProfileImageAssetId()) + .isEqualTo(asset.getPublicId()); + assertBound(asset, ImageBindingStatus.BOUND); + Snapshot snapshot = checkpointStore.find(lease.runId()); + assertThat(snapshot.cursorId()).isEqualTo(user.getId()); + assertThat(snapshot.scannedCount()).isEqualTo(1); + assertThat(snapshot.attachedCount()).isEqualTo(1); + } + + @Test + void lease_fence_실패는_프로필_UUID_media_claim과_cursor를_모두_rollback한다() { + User user = saveUser("profiles/rollback.jpg"); + Map before = userDetails(user.getId()); + ImageAsset asset = readyAsset(); + Lease lease = acquire(UUID.randomUUID(), user.getId()); + expire(lease); + Snapshot checkpointBefore = checkpointStore.find(lease.runId()); + + assertThatThrownBy(() -> attachmentService.attachAndRecord( + candidate(user), info(asset), lease, LEASE_DURATION)) + .isInstanceOf(UserProfileBackfillLeaseLostException.class); + + assertThat(userDetails(user.getId())).isEqualTo(before); + assertThat(userRepository.findById(user.getId()).orElseThrow().getProfileImageAssetId()).isNull(); + assertBound(asset, ImageBindingStatus.UNBOUND); + assertThat(checkpointStore.find(lease.runId())).isEqualTo(checkpointBefore); + } + + @Test + void 탈퇴가_먼저_commit되면_잠금_대기하던_attach는_탈퇴_상태를_유지한다() throws Exception { + User user = saveUser("profiles/deleted.jpg"); + ImageAsset asset = readyAsset(); + Lease lease = acquire(UUID.randomUUID(), user.getId()); + + UserProfileBackfillOutcome outcome = raceAgainstOwnerWrite(user, asset, lease, () -> { + userRepository.deleteById(user.getId()); + userRepository.flush(); + }); + + assertThat(outcome).isEqualTo(UserProfileBackfillOutcome.SKIPPED); + assertThat(userRepository.findById(user.getId())).isEmpty(); + assertThat(jdbcTemplate.queryForObject( + "SELECT deleted FROM users WHERE id = ?", Boolean.class, user.getId())).isTrue(); + assertThat(jdbcTemplate.queryForObject( + "SELECT profile_image_asset_id FROM users WHERE id = ?", String.class, user.getId())).isNull(); + assertBound(asset, ImageBindingStatus.UNBOUND); + assertThat(checkpointStore.find(lease.runId()).skippedCount()).isEqualTo(1L); + } + + @Test + void source_수정이_먼저_commit되면_잠금_대기하던_attach는_이전_사진을_연결하지_않는다() throws Exception { + User user = saveUser("profiles/old.jpg"); + ImageAsset asset = readyAsset(); + Lease lease = acquire(UUID.randomUUID(), user.getId()); + + // 현재 profile 수정 API는 없다. owner UPDATE가 같은 User 행을 잠그는 DB 경쟁을 검증한다. + UserProfileBackfillOutcome outcome = raceAgainstOwnerWrite(user, asset, lease, () -> + jdbcTemplate.update("UPDATE users SET profile_image_key = ? WHERE id = ?", + "profiles/new.jpg", user.getId())); + + assertThat(outcome).isEqualTo(UserProfileBackfillOutcome.SKIPPED); + User current = userRepository.findById(user.getId()).orElseThrow(); + assertThat(current.getProfileImageKey()).isEqualTo("profiles/new.jpg"); + assertThat(current.getProfileImageAssetId()).isNull(); + assertBound(asset, ImageBindingStatus.UNBOUND); + assertThat(checkpointStore.find(lease.runId()).skippedCount()).isEqualTo(1L); + } + + @Test + void 서로_다른_run이_같은_프로필을_연결해도_한_asset만_claim한다() throws Exception { + User user = saveUser("profiles/shared-slot.jpg"); + ImageAsset firstAsset = readyAsset(); + ImageAsset secondAsset = readyAsset(); + Lease firstLease = acquire(UUID.randomUUID(), user.getId()); + Lease secondLease = acquire(UUID.randomUUID(), user.getId()); + + UserProfileBackfillOutcome secondOutcome = raceAgainstOwnerWrite(user, secondAsset, secondLease, + () -> assertThat(attachmentService.attachAndRecord( + candidate(user), info(firstAsset), firstLease, LEASE_DURATION)) + .isEqualTo(UserProfileBackfillOutcome.ATTACHED)); + + assertThat(secondOutcome).isEqualTo(UserProfileBackfillOutcome.SKIPPED); + assertThat(userRepository.findById(user.getId()).orElseThrow().getProfileImageAssetId()) + .isEqualTo(firstAsset.getPublicId()); + assertBound(firstAsset, ImageBindingStatus.BOUND); + assertBound(secondAsset, ImageBindingStatus.UNBOUND); + assertThat(checkpointStore.find(firstLease.runId()).attachedCount()).isEqualTo(1); + assertThat(checkpointStore.find(secondLease.runId()).skippedCount()).isEqualTo(1); + } + + @Test + void checkpoint에는_개인정보_경로_또는_asset_식별자_컬럼을_추가하지_않는다() { + assertThat(jdbcTemplate.queryForList(""" + SELECT column_name FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'user_profile_backfill_checkpoint' + ORDER BY ordinal_position + """, String.class)).containsExactly( + "run_id", "mode", "status", "upper_bound_id", "cursor_id", "lease_token", "lease_until", + "scanned_count", "prepared_count", "attached_count", "skipped_count", "failed_count", + "created_at", "updated_at"); + } + + @Test + void keyset_후보_조회는_기존_PK_또는_asset_인덱스로_범위를_제한한다() { + List rows = new ArrayList<>(); + for (int index = 1; index <= 2_000; index++) { + rows.add(new Object[]{"plan-" + index, "010%08d".formatted(index), + "plan-" + index + "@hashi.test", "profiles/plan.jpg"}); + } + jdbcTemplate.batchUpdate(""" + INSERT INTO users (nickname, name_eng, birth_date, phone, email, profile_image_key, + deleted, created_at, updated_at) + VALUES (?, 'HASHI', '1998-01-01', ?, ?, ?, false, CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6)) + """, rows); + jdbcTemplate.execute("ANALYZE TABLE users"); + long upper = candidateReader.findUpperBound(); + Map plan = jdbcTemplate.queryForMap(""" + EXPLAIN SELECT id, profile_image_key + FROM users + WHERE deleted = false AND profile_image_key IS NOT NULL AND profile_image_asset_id IS NULL + AND id > ? AND id <= ? + ORDER BY id ASC LIMIT 50 + """, upper - 1_000, upper); + + assertThat(plan.get("type")).as("keyset query plan: %s", plan).isIn("range", "ref"); + assertThat(plan.get("key")).as("keyset query plan: %s", plan) + .isIn("PRIMARY", "uq_users_profile_image_asset_id"); + } + + private Lease acquire(UUID runId, long upperBound) { + Acquisition acquisition = checkpointStore.acquire( + runId, UserProfileBackfillMode.ATTACH, upperBound, LEASE_DURATION); + assertThat(acquisition.state()).isEqualTo(AcquisitionState.ACQUIRED); + return acquisition.lease(); + } + + private void expire(Lease lease) { + jdbcTemplate.update(""" + UPDATE user_profile_backfill_checkpoint + SET lease_until = TIMESTAMPADD(SECOND, -1, CURRENT_TIMESTAMP(6)) + WHERE run_id = ? + """, lease.runId().toString()); + } + + private UserProfileBackfillCandidate candidate(User user) { + return new UserProfileBackfillCandidate(user.getId(), user.getProfileImageKey()); + } + + private ImageAsset readyAsset() { + UUID assetId = UUID.randomUUID(); + String identity = UUID.randomUUID().toString().replace("-", "").repeat(2); + LocalDateTime now = LocalDateTime.now(clock); + ImageAsset asset = ImageAsset.createSystemBackfill(assetId, MediaPurpose.PROFILE, + "media/originals/%s/original".formatted(assetId), "image/jpeg", 1024L, + now.plusMinutes(5), identity); + UUID jobId = UUID.randomUUID(); + asset.beginInitialProcessing("version-1", "\"etag-1\"", 1, SPEC_DIGEST, jobId, now); + asset.completeCurrentProcessing( + jobId, 1, SPEC_DIGEST, "image/jpeg", 1024L, 3024, 4032, SOURCE_CHECKSUM); + return imageAssetRepository.saveAndFlush(asset); + } + + private MediaBackfillAssetInfo info(ImageAsset asset) { + return new MediaBackfillAssetInfo(asset.getPublicId(), MediaAssetPurpose.PROFILE, + asset.getBackfillIdentityHash(), MediaBackfillAssetInfo.State.READY); + } + + private void assertBound(ImageAsset asset, ImageBindingStatus status) { + assertThat(imageAssetRepository.findByPublicId(asset.getPublicId()).orElseThrow().getBindingStatus()) + .isEqualTo(status); + } + + private User saveUser(String legacyKey) { + String suffix = UUID.randomUUID().toString(); + return userRepository.saveAndFlush(User.onboard("profile-" + suffix, "HASHI", + LocalDate.of(1998, 1, 1), suffix.substring(0, 20), + suffix + "@hashi.test", legacyKey)); + } + + private Map userDetails(long userId) { + return jdbcTemplate.queryForMap(""" + SELECT nickname, name_eng, birth_date, phone, email, profile_image_key, deleted, created_at + FROM users WHERE id = ? + """, userId); + } + + private UserProfileBackfillOutcome raceAgainstOwnerWrite( + User user, ImageAsset asset, Lease lease, Runnable ownerWrite + ) throws Exception { + CountDownLatch changed = new CountDownLatch(1); + CountDownLatch allowCommit = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future update = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> { + ownerWrite.run(); + changed.countDown(); + await(allowCommit); + })); + assertThat(changed.await(10, TimeUnit.SECONDS)).isTrue(); + Future attach = executor.submit(() -> + attachmentService.attachAndRecord(candidate(user), info(asset), lease, LEASE_DURATION)); + try { + awaitTableLockWait("users"); + } finally { + allowCommit.countDown(); + } + update.get(20, TimeUnit.SECONDS); + return attach.get(20, TimeUnit.SECONDS); + } finally { + allowCommit.countDown(); + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } + } + + private void awaitTableLockWait(String tableName) throws SQLException, InterruptedException { + try (Connection connection = DriverManager.getConnection( + MYSQL.getJdbcUrl(), "root", MYSQL.getPassword()); + PreparedStatement statement = connection.prepareStatement(""" + SELECT COUNT(*) + FROM performance_schema.data_lock_waits waits + JOIN performance_schema.data_locks requested + ON requested.ENGINE_LOCK_ID = waits.REQUESTING_ENGINE_LOCK_ID + WHERE requested.OBJECT_SCHEMA = ? AND requested.OBJECT_NAME = ? + """)) { + statement.setString(1, MYSQL.getDatabaseName()); + statement.setString(2, tableName); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + if (resultSet.getInt(1) > 0) { + return; + } + } + Thread.sleep(50); + } + } + throw new AssertionError("backfill row-lock wait was not observed"); + } + + private void awaitLeaseExpiry(UUID runId) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + Boolean expired = jdbcTemplate.queryForObject(""" + SELECT CURRENT_TIMESTAMP(6) >= lease_until + FROM user_profile_backfill_checkpoint WHERE run_id = ? + """, Boolean.class, runId.toString()); + if (Boolean.TRUE.equals(expired)) { + return; + } + Thread.sleep(50); + } + throw new AssertionError("backfill lease did not expire within the test bound"); + } + + private void await(CountDownLatch latch) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("backfill transaction wait timed out"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("backfill transaction wait was interrupted"); + } + } +} diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPropertiesTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPropertiesTest.java new file mode 100644 index 00000000..a5734f94 --- /dev/null +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPropertiesTest.java @@ -0,0 +1,75 @@ +package org.sopt.hashi.user.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class UserProfileBackfillPropertiesTest { + + @Test + void PREPARE와_ATTACH는_canonical_run_ID가_필수다() { + UUID runId = UUID.randomUUID(); + + UserProfileBackfillProperties properties = properties( + true, runId.toString(), UserProfileBackfillMode.PREPARE, + 50, 10, Duration.ofMinutes(5), 3, Duration.ofMillis(200)); + + assertThat(properties.requiredRunId()).isEqualTo(runId); + assertThatThrownBy(() -> properties( + true, "", UserProfileBackfillMode.ATTACH, + 50, 10, Duration.ofMinutes(5), 3, Duration.ofMillis(200))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> properties( + true, runId.toString().toUpperCase(), UserProfileBackfillMode.ATTACH, + 50, 10, Duration.ofMinutes(5), 3, Duration.ofMillis(200))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void DRY_RUN과_비활성_설정은_run_ID없이_구성할_수_있다() { + assertThat(properties( + true, "", UserProfileBackfillMode.DRY_RUN, + 50, 10, Duration.ofMinutes(5), 3, Duration.ZERO).enabled()).isTrue(); + assertThat(properties( + false, "", UserProfileBackfillMode.ATTACH, + 50, 10, Duration.ofMinutes(5), 3, Duration.ZERO).enabled()).isFalse(); + } + + @Test + void batch_lease_retry_범위를_벗어난_설정은_거부한다() { + assertThatThrownBy(() -> properties( + false, "", UserProfileBackfillMode.DRY_RUN, + 0, 10, Duration.ofMinutes(5), 3, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> properties( + false, "", UserProfileBackfillMode.DRY_RUN, + 50, 10, Duration.ofSeconds(29), 3, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> properties( + false, "", UserProfileBackfillMode.DRY_RUN, + 50, 10, Duration.ofMinutes(5), 6, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> properties( + false, "", UserProfileBackfillMode.DRY_RUN, + 50, 10, Duration.ofMinutes(5), 3, Duration.ofSeconds(11))) + .isInstanceOf(IllegalArgumentException.class); + } + + private UserProfileBackfillProperties properties( + boolean enabled, + String runId, + UserProfileBackfillMode mode, + int batchSize, + int maxBatches, + Duration leaseDuration, + int maxAttempts, + Duration retryDelay + ) { + return new UserProfileBackfillProperties( + enabled, runId, + mode, batchSize, maxBatches, leaseDuration, maxAttempts, retryDelay); + } +} diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java new file mode 100644 index 00000000..9e6296b0 --- /dev/null +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java @@ -0,0 +1,264 @@ +package org.sopt.hashi.user.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +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 java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.media.MediaAssetPurpose; +import org.sopt.hashi.media.MediaBackfillAssetInfo; +import org.sopt.hashi.media.MediaBackfillAssetInfo.State; +import org.sopt.hashi.media.MediaBackfillInspectionInfo; +import org.sopt.hashi.media.MediaBackfillPort; +import org.sopt.hashi.media.MediaBackfillSourceException; +import org.sopt.hashi.media.MediaBackfillSourceException.Reason; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Acquisition; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.AcquisitionState; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Lease; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Snapshot; +import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Status; + +class UserProfileBackfillRunnerTest { + + private static final String IDENTITY = "a".repeat(64); + + private final UserProfileBackfillCandidateReader candidateReader = + mock(UserProfileBackfillCandidateReader.class); + private final UserProfileBackfillCheckpointStore checkpointStore = + mock(UserProfileBackfillCheckpointStore.class); + private final UserProfileBackfillAttachmentService attachmentService = + mock(UserProfileBackfillAttachmentService.class); + private final MediaBackfillPort mediaBackfillPort = mock(MediaBackfillPort.class); + + @Test + void DRY_RUN은_inspect만_수행하고_DB나_asset을_변경하지_않는다() { + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.DRY_RUN, "", 2, 1, 3); + UserProfileBackfillCandidate first = candidate(1L); + UserProfileBackfillCandidate second = candidate(2L); + given(candidateReader.findUpperBound()).willReturn(2L); + given(candidateReader.findBatch(0L, 2L, 2)) + .willReturn(List.of(first, second)); + given(candidateReader.findBatch(2L, 2L, 1)) + .willReturn(List.of()); + given(mediaBackfillPort.inspect(any())).willReturn(unpreparedInspection()); + + UserProfileBackfillSummary summary = runner(properties).execute(); + + assertThat(summary.status()).isEqualTo(UserProfileBackfillSummary.Status.COMPLETED); + assertThat(summary.scannedCount()).isEqualTo(2); + assertThat(summary.inspectedCount()).isEqualTo(2); + verify(mediaBackfillPort, times(2)).inspect(any()); + verify(mediaBackfillPort, never()).prepare(any(), any()); + verify(mediaBackfillPort, never()).claimReady(any()); + verify(checkpointStore, never()).acquire(any(), any(), anyLong(), any()); + verifyNoInteractions(checkpointStore, attachmentService); + } + + @Test + void PREPARE는_같은_identity를_준비하고_항목별_cursor를_기록한다() { + UUID runId = UUID.randomUUID(); + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.PREPARE, runId.toString(), 2, 1, 3); + Lease lease = lease(runId, properties.mode(), 1L); + given(candidateReader.findUpperBound()).willReturn(1L); + given(checkpointStore.acquire( + eq(runId), eq(properties.mode()), eq(1L), any())) + .willReturn(new Acquisition(AcquisitionState.ACQUIRED, lease, snapshot( + runId, properties.mode(), Status.RUNNING, 1L, 0L, 0, 0, 0, 0, 0))); + given(candidateReader.findBatch(0L, 1L, 2)) + .willReturn(List.of(candidate(1L))); + given(mediaBackfillPort.inspect(any())).willReturn(unpreparedInspection()); + given(mediaBackfillPort.prepare(any(), eq(IDENTITY))) + .willReturn(asset(State.PROCESSING)); + given(checkpointStore.complete(lease)).willReturn(snapshot( + runId, properties.mode(), Status.COMPLETED, 1L, 1L, 1, 1, 0, 0, 0)); + + UserProfileBackfillSummary summary = runner(properties).execute(); + + assertThat(summary.status()).isEqualTo(UserProfileBackfillSummary.Status.COMPLETED); + assertThat(summary.preparedCount()).isEqualTo(1); + verify(mediaBackfillPort).prepare(any(), eq(IDENTITY)); + verify(checkpointStore).recordProgress( + lease, 1L, UserProfileBackfillOutcome.PREPARED, properties.leaseDuration()); + } + + @Test + void ATTACH는_READY일_때만_aggregate_transaction에_연결과_cursor를_위임한다() { + UUID runId = UUID.randomUUID(); + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.ATTACH, runId.toString(), 2, 1, 3); + Lease lease = lease(runId, properties.mode(), 1L); + UserProfileBackfillCandidate candidate = candidate(1L); + MediaBackfillAssetInfo ready = asset(State.READY); + given(candidateReader.findUpperBound()).willReturn(1L); + given(checkpointStore.acquire( + eq(runId), eq(properties.mode()), eq(1L), any())) + .willReturn(new Acquisition(AcquisitionState.ACQUIRED, lease, snapshot( + runId, properties.mode(), Status.RUNNING, 1L, 0L, 0, 0, 0, 0, 0))); + given(candidateReader.findBatch(0L, 1L, 2)) + .willReturn(List.of(candidate)); + given(mediaBackfillPort.inspect(any())).willReturn(inspection(ready)); + given(attachmentService.attachAndRecord( + candidate, ready, lease, properties.leaseDuration())) + .willReturn(UserProfileBackfillOutcome.ATTACHED); + given(checkpointStore.complete(lease)).willReturn(snapshot( + runId, properties.mode(), Status.COMPLETED, 1L, 1L, 1, 0, 1, 0, 0)); + + UserProfileBackfillSummary summary = runner(properties).execute(); + + assertThat(summary.attachedCount()).isEqualTo(1); + verify(attachmentService).attachAndRecord( + candidate, ready, lease, properties.leaseDuration()); + verify(checkpointStore, never()).recordProgress( + eq(lease), eq(1L), any(), eq(properties.leaseDuration())); + } + + @Test + void 일시적인_storage_장애는_제한된_횟수만_재시도한다() { + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.DRY_RUN, "", 2, 1, 3); + given(candidateReader.findUpperBound()).willReturn(1L); + given(candidateReader.findBatch(0L, 1L, 2)) + .willReturn(List.of(candidate(1L))); + given(mediaBackfillPort.inspect(any())) + .willThrow(new MediaBackfillSourceException(Reason.STORAGE_UNAVAILABLE)); + + UserProfileBackfillSummary summary = runner(properties).execute(); + + assertThat(summary.failedCount()).isEqualTo(1); + verify(mediaBackfillPort, times(3)).inspect(any()); + } + + @Test + void 다른_worker가_lease를_보유하면_후보를_처리하지_않는다() { + UUID runId = UUID.randomUUID(); + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.ATTACH, runId.toString(), 2, 1, 3); + Snapshot busy = snapshot( + runId, properties.mode(), Status.RUNNING, 10L, 3L, 3, 0, 1, 2, 0); + given(candidateReader.findUpperBound()).willReturn(10L); + given(checkpointStore.acquire( + eq(runId), eq(properties.mode()), eq(10L), any())) + .willReturn(new Acquisition(AcquisitionState.BUSY, null, busy)); + + UserProfileBackfillSummary summary = runner(properties).execute(); + + assertThat(summary.status()).isEqualTo(UserProfileBackfillSummary.Status.BUSY); + verify(candidateReader, never()).findBatch(anyLong(), anyLong(), anyInt()); + verify(mediaBackfillPort, never()).inspect(any()); + } + + + @Test + void ATTACH는_PROCESSING을_기다리지_않고_다음_실행에서_재검사하도록_건너뛴다() { + UUID runId = UUID.randomUUID(); + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.ATTACH, runId.toString(), 2, 1, 3); + Lease lease = lease(runId, properties.mode(), 1L); + given(candidateReader.findUpperBound()).willReturn(1L); + given(checkpointStore.acquire(eq(runId), eq(properties.mode()), eq(1L), any())) + .willReturn(new Acquisition(AcquisitionState.ACQUIRED, lease, snapshot( + runId, properties.mode(), Status.RUNNING, 1L, 0L, 0, 0, 0, 0, 0))); + given(candidateReader.findBatch(0L, 1L, 2)).willReturn(List.of(candidate(1L))); + given(mediaBackfillPort.inspect(any())).willReturn(inspection(asset(State.PROCESSING))); + given(checkpointStore.complete(lease)).willReturn(snapshot( + runId, properties.mode(), Status.COMPLETED, 1L, 1L, 1, 0, 0, 1, 0)); + + UserProfileBackfillSummary summary = runner(properties).execute(); + + assertThat(summary.skippedCount()).isEqualTo(1); + verify(attachmentService, never()).attachAndRecord(any(), any(), any(), any()); + verify(mediaBackfillPort, never()).prepare(any(), any()); + verify(checkpointStore).recordProgress( + lease, 1L, UserProfileBackfillOutcome.SKIPPED, properties.leaseDuration()); + } + + @Test + void 영구적인_source_오류는_재시도하지_않고_민감한_후보를_문자열로_노출하지_않는다() { + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.DRY_RUN, "", 2, 1, 3); + given(candidateReader.findUpperBound()).willReturn(1L); + given(candidateReader.findBatch(0L, 1L, 2)).willReturn(List.of(candidate(1L))); + given(mediaBackfillPort.inspect(any())) + .willThrow(new MediaBackfillSourceException(Reason.SOURCE_MISSING)); + + assertThat(runner(properties).execute().failedCount()).isEqualTo(1); + + verify(mediaBackfillPort).inspect(any()); + assertThat(candidate(1L).toString()).doesNotContain("profiles/legacy.jpg", "userId=1"); + } + + private UserProfileBackfillRunner runner(UserProfileBackfillProperties properties) { + return new UserProfileBackfillRunner( + properties, candidateReader, checkpointStore, attachmentService, mediaBackfillPort); + } + + private UserProfileBackfillProperties properties( + UserProfileBackfillMode mode, + String runId, + int batchSize, + int maxBatches, + int maxAttempts + ) { + return new UserProfileBackfillProperties( + true, runId, + mode, batchSize, maxBatches, Duration.ofMinutes(5), maxAttempts, Duration.ZERO); + } + + private UserProfileBackfillCandidate candidate(long userId) { + return new UserProfileBackfillCandidate(userId, "profiles/legacy.jpg"); + } + + private Lease lease(UUID runId, UserProfileBackfillMode mode, long upperBound) { + return new Lease( + runId, UUID.randomUUID(), + mode, upperBound); + } + + private Snapshot snapshot( + UUID runId, + UserProfileBackfillMode mode, + Status status, + long upperBound, + long cursor, + long scanned, + long prepared, + long attached, + long skipped, + long failed + ) { + return new Snapshot( + runId, mode, status, + upperBound, cursor, status == Status.RUNNING ? LocalDateTime.now().plusMinutes(1) : null, + scanned, prepared, attached, skipped, failed); + } + + private MediaBackfillInspectionInfo unpreparedInspection() { + return new MediaBackfillInspectionInfo( + IDENTITY, MediaAssetPurpose.PROFILE, Optional.empty()); + } + + private MediaBackfillInspectionInfo inspection(MediaBackfillAssetInfo asset) { + return new MediaBackfillInspectionInfo( + IDENTITY, MediaAssetPurpose.PROFILE, Optional.of(asset)); + } + + private MediaBackfillAssetInfo asset(State state) { + return new MediaBackfillAssetInfo( + UUID.randomUUID(), MediaAssetPurpose.PROFILE, IDENTITY, state); + } +} From 17073ab7f5aa8d117539cc4a2d2dbfcff959e4b5 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 4 Sep 2026 12:19:26 +0900 Subject: [PATCH 02/16] =?UTF-8?q?docs(media):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=A0=88=EC=B0=A8=20=EC=B6=94=EA=B0=80=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/media/legacy-backfill-runbook.md | 9 +- docs/media/user-profile-backfill-runbook.md | 124 ++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 docs/media/user-profile-backfill-runbook.md diff --git a/docs/media/legacy-backfill-runbook.md b/docs/media/legacy-backfill-runbook.md index a39faca3..79b2529e 100644 --- a/docs/media/legacy-backfill-runbook.md +++ b/docs/media/legacy-backfill-runbook.md @@ -5,8 +5,9 @@ 이 문서는 공통 기반의 사용 경계와 후속 runner의 요구사항이다. 실제 AWS 적용이나 운영 backfill을 승인하지 않는다. 식당·메뉴의 dry-run, checkpoint와 bounded runner는 -[식당·메뉴 실행기](restaurant-menu-backfill-runbook.md)를 따른다. 다른 도메인 runner와 운영 전환 -검증은 후속 작업이며, 리뷰 도메인 연동은 #179 병합 후 진행한다. +[식당·메뉴 실행기](restaurant-menu-backfill-runbook.md)를, 활성 회원의 프로필 전환은 +[프로필 실행기](user-profile-backfill-runbook.md)를 따른다. 매거진 runner와 운영 전환 검증은 +후속 작업이며, 리뷰 도메인 연동은 #179 병합 후 진행한다. ## 1. 소유 경계 @@ -152,7 +153,7 @@ WHERE creation_origin = 'SYSTEM_BACKFILL' CloudFront 전달 E2E가 완료됐다고 보고하지 않는다. 배포 전에는 dev의 제한된 테스트 source로 IAM과 전체 변환·연결 흐름을 별도 검증해야 한다. -식당·메뉴 runner의 keyset batch·checkpoint·dry-run과 동시 수정 검증은 별도 실행기 문서를 따른다. -후속 작업은 다른 도메인 runner, 안전한 cleanup/reconciliation, dev E2E와 운영 승인이다. +식당·메뉴와 프로필 runner의 keyset batch·checkpoint·dry-run과 동시 수정 검증은 별도 실행기 문서를 따른다. +후속 작업은 매거진 runner, 안전한 cleanup/reconciliation, dev E2E와 운영 승인이다. legacy 필드 제거와 원본 삭제는 별도 종료 조건과 승인을 충족하기 전에는 실행하지 않는다. diff --git a/docs/media/user-profile-backfill-runbook.md b/docs/media/user-profile-backfill-runbook.md new file mode 100644 index 00000000..c8c645ba --- /dev/null +++ b/docs/media/user-profile-backfill-runbook.md @@ -0,0 +1,124 @@ +# 프로필 이미지 backfill 실행기 + +관련 이슈: #199. [공통 backfill 계약](legacy-backfill-runbook.md)을 사용하는 `user.migration`의 +임시 실행기다. 코드 배포와 이 문서는 실제 AWS 적용, 운영 backfill, 개인정보 삭제를 승인하지 않는다. + +## 1. 대상과 소유 경계 + +- `users.deleted=false`, legacy `profile_image_key` 존재, `profile_image_asset_id` 없음인 회원만 조사한다. + 탈퇴 회원은 일반 User 조회에서 제외되는 기존 정책을 따른다. 과거 예약 표시를 위해 삭제된 식당도 + 처리하는 식당 runner와 의도적으로 다르다. +- 후보 조회는 ID와 legacy key만 읽는다. 별도 사용자 목록, 전화번호, 이메일 또는 이름을 적재하지 않는다. +- 공개 API, 로그인 actor, 반복 scheduler, 신규 dependency는 추가하지 않는다. +- `MediaBackfillTarget.USER_PROFILE`의 기존 identity marker를 그대로 사용한다. 콘텐츠 소유권은 + User가 유지하며 media의 Entity/Repository를 production 코드에서 직접 참조하지 않는다. +- 기본 비활성이다. 명시적으로 opt-in한 애플리케이션의 `ApplicationReadyEvent` 이후 전용 + `userProfileBackfillExecutor`에서 한 번만 실행하고 최대 batch 수에 도달하면 멈춘다. + +## 2. 조사 → 준비 → 연결 + +| mode | 동작 | 쓰기 | +| --- | --- | --- | +| `DRY_RUN` | 후보 읽기, S3 HEAD와 기존 media 예약 조사 | DB·S3 쓰기 없음 | +| `PREPARE` | 동일 source identity의 private original copy·변환 job 준비 | media 예약·copy·job·checkpoint | +| `ATTACH` | 현재 source 재조사 후 READY asset 연결 | User 프로필 UUID·media claim·checkpoint | + +PREPARE는 변환 완료를 기다리지 않으며 기존 프로필과 legacy URL을 유지한다. READY 이후 ATTACH로 +연결하고 legacy key는 제거하지 않는다. 닉네임·이름·생일·전화번호·이메일·탈퇴 상태는 변경하지 않는다. + +원본을 임시 공개하거나 PROCESSING/FAILED asset으로 기존 프로필을 교체하지 않는다. +ATTACH에서 PROCESSING은 건너뛰며, 준비 완료 후 새 ATTACH run으로 재조사한다. + +## 3. 실행 전 확인 + +1. 공통 media·worker·result pipeline의 승인된 dev E2E를 먼저 완료한다. +2. SAM `BackfillAccessEnabled`와 Spring `AWS_MEDIA_BACKFILL_ENABLED`의 별도 승인을 확인한다. +3. PREPARE는 DB `media_pipeline_config.issuance_enabled=true`와 배포 규격 일치가 필요하다. + 조회와 READY ATTACH는 issuance pause 상태에서도 가능하다. +4. 프로필 runner 설정을 별도로 opt-in한다. V23 migration 자체는 작업을 실행하지 않는다. +5. legacy S3 객체를 같은 key로 직접 덮어쓰지 않는다. 이후 사진 변경은 새 key를 사용한다. + +S3 HEAD/copy와 User DB 잠금은 하나의 원자적 작업이 아니다. 조사 이후 source가 바뀌면 +prepare의 identity 검증이나 ATTACH의 잠금 아래 key 재검증으로 이전 사진 연결을 거부한다. +단, 같은 key를 콘솔에서 직접 덮어쓰는 작업은 위 운영 통제가 필요하다. + +조사·준비 도중 사용자가 탈퇴하면 미연결 private copy가 남을 수 있다. ATTACH는 탈퇴한 User를 +연결하지 않으며, 미연결 파일은 승인된 cleanup/reconciliation 정책의 대상이다. 이 실행기는 +탈퇴 데이터 삭제나 보존 기간을 새로 정하거나 자동 삭제하지 않는다. + +## 4. 실행 설정 + +| 환경변수 | 기본값 | 의미 | +| --- | --- | --- | +| `USER_PROFILE_BACKFILL_ENABLED` | `false` | one-shot runner 활성화 | +| `USER_PROFILE_BACKFILL_RUN_ID` | 비어 있음 | PREPARE/ATTACH의 소문자 canonical UUID | +| `USER_PROFILE_BACKFILL_MODE` | `DRY_RUN` | 조사·준비·연결 중 하나 | +| `USER_PROFILE_BACKFILL_BATCH_SIZE` | `50` | 1~500개 후보 | +| `USER_PROFILE_BACKFILL_MAX_BATCHES` | `10` | 한 기동당 1~1,000 batch | +| `USER_PROFILE_BACKFILL_LEASE_DURATION` | `5m` | 30초~30분 | +| `USER_PROFILE_BACKFILL_MAX_ATTEMPTS` | `3` | storage 일시 장애의 총 시도 횟수, 1~5 | +| `USER_PROFILE_BACKFILL_RETRY_INITIAL_DELAY` | `200ms` | 0~10초, 지수 backoff 시작값 | + +- 프로필 슬롯만 처리하므로 target 선택 설정은 없다. +- PREPARE와 ATTACH는 서로 다른 run ID를 사용한다. 같은 run ID의 mode는 바꾸지 못한다. +- 여러 replica에는 같은 run ID를 사용한다. 유효한 lease를 얻은 한 실행기만 처리한다. +- 같은 mode를 서로 다른 run ID로 동시에 실행하지 않는다. User 잠금과 media claim이 이중 연결을 + 막더라도 불필요한 source 읽기·copy와 경합 비용이 발생한다. +- DRY_RUN은 checkpoint 없이 단일 조사 환경에서 수행한다. +- 지수 backoff의 총 대기 예산은 lease보다 짧아야 한다. source 지연까지 포함해 만료되면 + 현재 연결 transaction을 롤백하고 다음 기동에서 같은 run ID로 재개한다. + +## 5. 중단·복구와 정합성 + +V23의 `user_profile_backfill_checkpoint`는 user 소유 실행 기록이다. 다른 모듈 FK, media DB join, +PII, legacy key, asset UUID 또는 source hash를 저장하지 않는다. run ID·진행 ID 범위·lease·집계만 둔다. + +- 최초 후보 User ID 상한을 고정하고 `id > cursor AND id <= upper_bound`로 순회한다. + 이후 생성된 회원은 다음 run에서 조사한다. 이미 있던 기본 프로필의 변경도 재조사가 필요할 수 있다. +- PREPARE는 항목별로 진행 위치를 커밋한다. copy 이후 중단되어도 같은 identity의 예약·copy·job을 재사용한다. +- ATTACH는 User 잠금 → 활성 상태·legacy key·기존 UUID 재검증 → media READY claim → cursor 갱신을 + 같은 transaction에서 처리한다. 연결·claim·cursor 중 하나라도 실패하면 모두 롤백한다. +- 탈퇴나 source 변경이 먼저 커밋되면 해당 항목을 SKIPPED로 기록한다. 새로운 사진으로 덮어쓰지 않는다. +- checkpoint 잠금 뒤 별도 SQL의 DB 시각으로 lease 만료를 판단한다. 대기 전에 읽은 시각으로 연장하지 않는다. +- 최대 batch 도달은 PAUSED다. 같은 run ID로 다음 기동에서 이어간다. 강제 종료로 RUNNING이 남으면 + 만료 후 새로운 token으로 인계받고, 이전 실행기는 진행 위치를 갱신하거나 새 lease를 해제할 수 없다. +- COMPLETED는 **정해진 ID 범위의 순회 완료**다. 전체 프로필 전환 완료를 뜻하지 않는다. + 변환 대기·실패·동시 수정으로 남은 항목은 원인을 확인하고 새로운 run에서 처리한다. +- 종료 시 executor의 graceful-stop 대기 이후 진행 중 작업은 interrupt 대상이다. 커밋되지 않은 + 작업은 롤백 또는 멱등 재시도로 복구하며 lease 해제가 불가능하면 만료를 기다린다. + +현재 별도 프로필 수정 API는 없다. 향후 프로필 수정·탈퇴 경로를 추가할 때에도 User 행의 +동시 변경과 media claim/retire 경계를 함께 검토해야 한다. 이 작업에서 해당 API를 추가하지 않는다. + +## 6. 관측과 실패 대응 + +로그에는 mode·상태·집계와 오류 클래스명만 남긴다. 원시 User ID, key, asset UUID, hash, +개인정보 또는 예외 payload를 출력하지 않는다. PREPARE/ATTACH 결과는 다음 집계로 확인한다. + +```sql +SELECT mode, status, scanned_count, prepared_count, attached_count, skipped_count, failed_count +FROM user_profile_backfill_checkpoint +WHERE run_id = ?; +``` + +PREPARED는 변환 완료가 아니라 준비 단계 처리 수다. SKIPPED는 미준비 또는 변경된 프로필, +FAILED는 source 오류 또는 terminal media 상태다. DB·설정·불변식 오류는 현재 cursor를 전진시키지 +않고 실행을 중단한다. `STORAGE_UNAVAILABLE`만 정해진 횟수 내에서 재시도한다. +run ID만 바꿔 장애를 무한 반복하지 말고 원인을 확인한다. + +## 7. 검증과 전환 + +```text +./gradlew test --tests 'org.sopt.hashi.user.migration.*' --tests '*UserProfileImageTest' --tests '*MediaBackfillBoundaryTest' --tests 'org.sopt.hashi.ModularityTests' +./gradlew clean build +``` + +실제 MySQL에서 V23 제약·상한·재개·lease takeover와 대기 중 만료, User/media/checkpoint 롤백, +탈퇴·source 수정·동일 슬롯 이중 연결 경쟁, keyset 실행 계획을 검증한다. 시작 이벤트와 executor +종료 테스트는 모킹한 storage를 사용한다. 운영의 종료 대기 시간이나 AWS 지연을 측정한 것은 아니다. + +기존 backfill CI를 확장해 식당과 프로필 MySQL suite 모두 실행 수 > 0, 실패·오류·skip 0을 요구한다. +Docker가 없어서 건너뛴 결과는 검증 완료가 아니다. + +실제 AWS dev dry-run → 제한 PREPARE → READY 확인 → 제한 ATTACH → 프로필 응답·전송량 확인은 +별도 승인 후 실행한다. 운영 범위·일정 승인, legacy 제거, 원본 삭제는 이 PR에서 수행하지 않는다. From 780ac4725ed2bfd9f34cc4642587a38ef3f66962 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 4 Sep 2026 12:19:36 +0900 Subject: [PATCH 03/16] =?UTF-8?q?ci(config):=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20backfill=20MySQL=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ci-restaurant-media-backfill.yml | 32 ++++++++----- .../UserProfileBackfillWorkflowTest.java | 46 +++++++++++++++++++ 2 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillWorkflowTest.java diff --git a/.github/workflows/ci-restaurant-media-backfill.yml b/.github/workflows/ci-restaurant-media-backfill.yml index dd88e958..1f38426f 100644 --- a/.github/workflows/ci-restaurant-media-backfill.yml +++ b/.github/workflows/ci-restaurant-media-backfill.yml @@ -1,4 +1,4 @@ -name: Restaurant Media Backfill CI +name: Media Backfill CI on: pull_request: @@ -6,16 +6,25 @@ on: - ".github/workflows/ci-restaurant-media-backfill.yml" - "src/main/java/org/sopt/hashi/restaurant/**" - "src/test/java/org/sopt/hashi/restaurant/**" + - "src/main/java/org/sopt/hashi/user/**" + - "src/test/java/org/sopt/hashi/user/**" - "src/main/resources/db/migration/**" - "src/main/resources/application.yml" - "docs/media/restaurant-menu-backfill-runbook.md" + - "docs/media/user-profile-backfill-runbook.md" + - "docs/media/legacy-backfill-runbook.md" + - "build.gradle" + +concurrency: + group: media-backfill-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: contents: read jobs: verify: - name: Verify restaurant media backfill + name: Verify media backfill runs-on: ubuntu-latest timeout-minutes: 30 @@ -44,14 +53,15 @@ jobs: from pathlib import Path import xml.etree.ElementTree as ET - report = Path( - "build/test-results/test/" - "TEST-org.sopt.hashi.restaurant.migration." - "RestaurantMediaBackfillPersistenceIntegrationTest.xml" + reports = ( + "TEST-org.sopt.hashi.restaurant.migration.RestaurantMediaBackfillPersistenceIntegrationTest.xml", + "TEST-org.sopt.hashi.user.migration.UserProfileBackfillPersistenceIntegrationTest.xml", ) - suite = ET.parse(report).getroot() - assert int(suite.attrib["tests"]) > 0, "MySQL backfill tests did not execute" - for outcome in ("failures", "errors", "skipped"): - assert int(suite.attrib[outcome]) == 0, f"MySQL backfill {outcome} must be zero" - print("MySQL backfill gate executed without failures, errors, or skips") + for name in reports: + report = Path("build/test-results/test") / name + suite = ET.parse(report).getroot() + assert int(suite.attrib["tests"]) > 0, f"{name}: MySQL backfill tests did not execute" + for outcome in ("failures", "errors", "skipped"): + assert int(suite.attrib[outcome]) == 0, f"{name}: {outcome} must be zero" + print(f"{name}: executed without failures, errors, or skips") PY diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillWorkflowTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillWorkflowTest.java new file mode 100644 index 00000000..257e2640 --- /dev/null +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillWorkflowTest.java @@ -0,0 +1,46 @@ +package org.sopt.hashi.user.migration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +class UserProfileBackfillWorkflowTest { + + @Test + void hosted_CI는_exact_HEAD와_실제_Docker를_사용하고_MySQL_skip을_거부한다() throws IOException { + Map workflow; + try (var input = Files.newInputStream(Path.of( + ".github/workflows/ci-restaurant-media-backfill.yml"))) { + workflow = new Yaml().load(input); + } + Map permissions = (Map) workflow.get("permissions"); + Map jobs = (Map) workflow.get("jobs"); + Map verify = (Map) jobs.get("verify"); + List steps = (List) verify.get("steps"); + Map checkout = (Map) steps.getFirst(); + Map checkoutInputs = (Map) checkout.get("with"); + List commands = steps.stream() + .map(step -> (Map) step) + .filter(step -> step.containsKey("run")) + .map(step -> (String) step.get("run")) + .toList(); + + assertThat(permissions).hasSize(1); + assertThat(permissions.get("contents")).isEqualTo("read"); + assertThat((String) checkout.get("uses")).matches("actions/checkout@[0-9a-f]{40}"); + assertThat(checkoutInputs.get("ref")).isEqualTo("${{ github.event.pull_request.head.sha }}"); + assertThat(commands).contains("docker info", "bash ./gradlew clean build --no-daemon"); + assertThat(commands).anySatisfy(command -> { + assertThat(command).contains("RestaurantMediaBackfillPersistenceIntegrationTest.xml", + "UserProfileBackfillPersistenceIntegrationTest.xml"); + assertThat(command).contains("\"failures\", \"errors\", \"skipped\""); + assertThat(command).contains("== 0"); + }); + } +} From 52d010a0dc02e87de4f568361158b8528e610113 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 4 Sep 2026 12:29:04 +0900 Subject: [PATCH 04/16] =?UTF-8?q?fix(user):=20backfill=20lease=20=EC=83=81?= =?UTF-8?q?=EC=8B=A4=20=EC=83=81=ED=83=9C=20=EB=B3=B4=EA=B3=A0=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/UserProfileBackfillRunner.java | 4 +-- .../UserProfileBackfillRunnerTest.java | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java index 805e9c57..cc7ef106 100644 --- a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java @@ -149,9 +149,9 @@ private UserProfileBackfillSummary executePersistent() { if (!hasMore(cursor, lease.upperBoundId())) { return completedSummary(lease); } - checkpointStore.pause(lease); + boolean paused = checkpointStore.pause(lease); return UserProfileBackfillSummary.fromSnapshot( - Status.PAUSED, checkpointStore.find(lease.runId())); + paused ? Status.PAUSED : Status.LEASE_LOST, checkpointStore.find(lease.runId())); } catch (UserProfileBackfillLeaseLostException exception) { return UserProfileBackfillSummary.fromSnapshot( Status.LEASE_LOST, checkpointStore.find(lease.runId())); diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java index 9e6296b0..4d6d9244 100644 --- a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java @@ -18,6 +18,8 @@ import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.sopt.hashi.media.MediaAssetPurpose; import org.sopt.hashi.media.MediaBackfillAssetInfo; import org.sopt.hashi.media.MediaBackfillAssetInfo.State; @@ -202,6 +204,35 @@ class UserProfileBackfillRunnerTest { assertThat(candidate(1L).toString()).doesNotContain("profiles/legacy.jpg", "userId=1"); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void 최대_batch_후_pause가_거부되면_PAUSED가_아닌_LEASE_LOST를_보고한다(boolean paused) { + UUID runId = UUID.randomUUID(); + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.ATTACH, runId.toString(), 1, 1, 3); + Lease lease = lease(runId, properties.mode(), 2L); + given(candidateReader.findUpperBound()).willReturn(2L); + given(checkpointStore.acquire(eq(runId), eq(properties.mode()), eq(2L), any())) + .willReturn(new Acquisition(AcquisitionState.ACQUIRED, lease, snapshot( + runId, properties.mode(), Status.RUNNING, 2L, 0L, 0, 0, 0, 0, 0))); + given(candidateReader.findBatch(0L, 2L, 1)).willReturn(List.of(candidate(1L))); + given(candidateReader.findBatch(1L, 2L, 1)).willReturn(List.of(candidate(2L))); + given(mediaBackfillPort.inspect(any())).willReturn(inspection(asset(State.PROCESSING))); + given(checkpointStore.pause(lease)).willReturn(paused); + given(checkpointStore.find(runId)).willReturn(snapshot( + runId, properties.mode(), paused ? Status.PAUSED : Status.RUNNING, + 2L, 1L, 1, 0, 0, 1, 0)); + + UserProfileBackfillSummary summary = runner(properties).execute(); + + assertThat(summary.status()).isEqualTo(paused + ? UserProfileBackfillSummary.Status.PAUSED : UserProfileBackfillSummary.Status.LEASE_LOST); + assertThat(summary.scannedCount()).isEqualTo(1); + verify(checkpointStore).pause(lease); + verify(checkpointStore, never()).complete(any()); + verify(mediaBackfillPort).inspect(any()); + } + private UserProfileBackfillRunner runner(UserProfileBackfillProperties properties) { return new UserProfileBackfillRunner( properties, candidateReader, checkpointStore, attachmentService, mediaBackfillPort); From 59cf88fa8469bfb75e06c018dc309dad6fe38680 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sat, 5 Sep 2026 09:48:53 +0900 Subject: [PATCH 05/16] =?UTF-8?q?fix(user):=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20backfill=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=9E=A5=EC=95=A0?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EC=99=84=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/UserProfileBackfillRunner.java | 19 ++++++++-- .../UserProfileBackfillRunnerTest.java | 35 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java index cc7ef106..77ef4efb 100644 --- a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java @@ -67,7 +67,7 @@ public void runOnStartup() { } catch (RuntimeException exception) { log.error( "User profile backfill could not start: mode={}, errorType={}", - properties.mode(), exception.getClass().getSimpleName() + properties.mode(), failureType(exception) ); } } @@ -102,6 +102,7 @@ private UserProfileBackfillSummary executeDryRun() { inspect(candidate); summary.inspected++; } catch (MediaBackfillSourceException exception) { + rethrowInfrastructureFailure(exception); summary.failed++; } cursor = candidate.userId(); @@ -159,7 +160,7 @@ private UserProfileBackfillSummary executePersistent() { boolean paused = checkpointStore.pause(lease); log.error( "User profile backfill stopped: mode={}, errorType={}", - properties.mode(), exception.getClass().getSimpleName() + properties.mode(), failureType(exception) ); return UserProfileBackfillSummary.fromSnapshot( paused ? Status.FAILED : Status.LEASE_LOST, @@ -184,6 +185,7 @@ private void processAndRecord(UserProfileBackfillCandidate candidate, Lease leas } attachOrRecord(candidate, inspection, lease); } catch (MediaBackfillSourceException exception) { + rethrowInfrastructureFailure(exception); checkpointStore.recordProgress( lease, candidate.userId(), UserProfileBackfillOutcome.FAILED, properties.leaseDuration()); @@ -258,6 +260,19 @@ private T withStorageRetry(Supplier operation) { } } + private void rethrowInfrastructureFailure(MediaBackfillSourceException exception) { + if (exception.getReason() == Reason.STORAGE_UNAVAILABLE) { + throw exception; + } + } + + private String failureType(RuntimeException exception) { + if (exception instanceof MediaBackfillSourceException sourceException) { + return sourceException.getReason().name(); + } + return exception.getClass().getSimpleName(); + } + private Duration backoff(Duration initialDelay, int attempt) { return initialDelay.multipliedBy(1L << (attempt - 1)); } diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java index 4d6d9244..e2d6a55d 100644 --- a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java @@ -1,6 +1,7 @@ package org.sopt.hashi.user.migration; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; @@ -130,7 +131,7 @@ class UserProfileBackfillRunnerTest { } @Test - void 일시적인_storage_장애는_제한된_횟수만_재시도한다() { + void DRY_RUN의_storage_장애는_제한된_횟수_후_실행을_중단한다() { UserProfileBackfillProperties properties = properties( UserProfileBackfillMode.DRY_RUN, "", 2, 1, 3); given(candidateReader.findUpperBound()).willReturn(1L); @@ -139,10 +140,40 @@ class UserProfileBackfillRunnerTest { given(mediaBackfillPort.inspect(any())) .willThrow(new MediaBackfillSourceException(Reason.STORAGE_UNAVAILABLE)); + assertThatThrownBy(() -> runner(properties).execute()) + .isInstanceOfSatisfying(MediaBackfillSourceException.class, + exception -> assertThat(exception.getReason()) + .isEqualTo(Reason.STORAGE_UNAVAILABLE)); + verify(mediaBackfillPort, times(3)).inspect(any()); + } + + @Test + void PREPARE의_storage_장애는_cursor를_전진시키지_않고_실행을_중단한다() { + UUID runId = UUID.randomUUID(); + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.PREPARE, runId.toString(), 1, 1, 3); + Lease lease = lease(runId, properties.mode(), 1L); + Snapshot running = snapshot( + runId, properties.mode(), Status.RUNNING, 1L, 0L, 0, 0, 0, 0, 0); + Snapshot paused = snapshot( + runId, properties.mode(), Status.PAUSED, 1L, 0L, 0, 0, 0, 0, 0); + given(candidateReader.findUpperBound()).willReturn(1L); + given(checkpointStore.acquire(eq(runId), eq(properties.mode()), eq(1L), any())) + .willReturn(new Acquisition(AcquisitionState.ACQUIRED, lease, running)); + given(candidateReader.findBatch(0L, 1L, 1)) + .willReturn(List.of(candidate(1L))); + given(mediaBackfillPort.inspect(any())) + .willThrow(new MediaBackfillSourceException(Reason.STORAGE_UNAVAILABLE)); + given(checkpointStore.pause(lease)).willReturn(true); + given(checkpointStore.find(runId)).willReturn(paused); + UserProfileBackfillSummary summary = runner(properties).execute(); - assertThat(summary.failedCount()).isEqualTo(1); + assertThat(summary.status()).isEqualTo(UserProfileBackfillSummary.Status.FAILED); + assertThat(summary.scannedCount()).isZero(); verify(mediaBackfillPort, times(3)).inspect(any()); + verify(checkpointStore, never()).recordProgress(any(), anyLong(), any(), any()); + verify(checkpointStore).pause(lease); } @Test From 5f98b8338d22f8ecbdbdb8ce611453b42ffe5cc3 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sat, 5 Sep 2026 09:49:42 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix(user):=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20backfill=20=EC=B2=B4=ED=81=AC=ED=8F=AC=EC=9D=B8=ED=8A=B8=20I?= =?UTF-8?q?nnoDB=20=EB=AA=85=EC=8B=9C=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V23__create_user_profile_backfill_checkpoint.sql | 2 +- .../UserProfileBackfillPersistenceIntegrationTest.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V23__create_user_profile_backfill_checkpoint.sql b/src/main/resources/db/migration/V23__create_user_profile_backfill_checkpoint.sql index 63b5f8a7..2b137b67 100644 --- a/src/main/resources/db/migration/V23__create_user_profile_backfill_checkpoint.sql +++ b/src/main/resources/db/migration/V23__create_user_profile_backfill_checkpoint.sql @@ -33,4 +33,4 @@ CREATE TABLE user_profile_backfill_checkpoint ( AND lease_token REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') OR (status IN ('PAUSED', 'COMPLETED') AND lease_token IS NULL AND lease_until IS NULL) ) -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java index 29482e37..db331bf0 100644 --- a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java @@ -241,6 +241,11 @@ SELECT COUNT(*) FROM information_schema.key_column_usage AND table_name = 'user_profile_backfill_checkpoint' AND referenced_table_name IS NOT NULL """, Integer.class)).isZero(); + assertThat(jdbcTemplate.queryForObject(""" + SELECT engine FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'user_profile_backfill_checkpoint' + """, String.class)).isEqualToIgnoringCase("InnoDB"); } @Test From ad8e9de5da891e76fb6244698d993d9a1d9589a1 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sat, 5 Sep 2026 09:50:45 +0900 Subject: [PATCH 07/16] =?UTF-8?q?docs(media):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20backfill=20=EB=B3=B5=EA=B5=AC=20=EC=A0=88=EC=B0=A8?= =?UTF-8?q?=20=EB=B3=B4=EC=99=84=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/media/user-profile-backfill-runbook.md | 36 ++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/media/user-profile-backfill-runbook.md b/docs/media/user-profile-backfill-runbook.md index c8c645ba..5ed71a3f 100644 --- a/docs/media/user-profile-backfill-runbook.md +++ b/docs/media/user-profile-backfill-runbook.md @@ -31,12 +31,16 @@ ATTACH에서 PROCESSING은 건너뛰며, 준비 완료 후 새 ATTACH run으로 ## 3. 실행 전 확인 -1. 공통 media·worker·result pipeline의 승인된 dev E2E를 먼저 완료한다. +1. 공통 media·worker·result pipeline의 승인된 dev E2E를 먼저 완료한다. 실패 원인별 관측 보완(#203)도 + 반영되어 있어야 하며, 그전에는 운영 backfill을 실행하지 않는다. 2. SAM `BackfillAccessEnabled`와 Spring `AWS_MEDIA_BACKFILL_ENABLED`의 별도 승인을 확인한다. -3. PREPARE는 DB `media_pipeline_config.issuance_enabled=true`와 배포 규격 일치가 필요하다. +3. `PREPARE` 전에는 `AWS_MEDIA_QUEUE_ENABLED=true`, `AWS_MEDIA_RECOVERY_ENABLED=true`, worker event + source와 Spring result consumer가 활성 상태인지 확인한다. request/result queue와 DLQ 지연·오류 + alarm도 정상이어야 한다. +4. PREPARE는 DB `media_pipeline_config.issuance_enabled=true`와 배포 규격 일치가 필요하다. 조회와 READY ATTACH는 issuance pause 상태에서도 가능하다. -4. 프로필 runner 설정을 별도로 opt-in한다. V23 migration 자체는 작업을 실행하지 않는다. -5. legacy S3 객체를 같은 key로 직접 덮어쓰지 않는다. 이후 사진 변경은 새 key를 사용한다. +5. 프로필 runner 설정을 별도로 opt-in한다. V23 migration 자체는 작업을 실행하지 않는다. +6. legacy S3 객체를 같은 key로 직접 덮어쓰지 않는다. 이후 사진 변경은 새 key를 사용한다. S3 HEAD/copy와 User DB 잠금은 하나의 원자적 작업이 아니다. 조사 이후 source가 바뀌면 prepare의 identity 검증이나 ATTACH의 잠금 아래 key 재검증으로 이전 사진 연결을 거부한다. @@ -92,7 +96,7 @@ PII, legacy key, asset UUID 또는 source hash를 저장하지 않는다. run ID ## 6. 관측과 실패 대응 -로그에는 mode·상태·집계와 오류 클래스명만 남긴다. 원시 User ID, key, asset UUID, hash, +로그에는 mode·상태·집계와 오류 클래스명 또는 고정 실패 원인만 남긴다. 원시 User ID, key, asset UUID, hash, 개인정보 또는 예외 payload를 출력하지 않는다. PREPARE/ATTACH 결과는 다음 집계로 확인한다. ```sql @@ -102,9 +106,11 @@ WHERE run_id = ?; ``` PREPARED는 변환 완료가 아니라 준비 단계 처리 수다. SKIPPED는 미준비 또는 변경된 프로필, -FAILED는 source 오류 또는 terminal media 상태다. DB·설정·불변식 오류는 현재 cursor를 전진시키지 -않고 실행을 중단한다. `STORAGE_UNAVAILABLE`만 정해진 횟수 내에서 재시도한다. -run ID만 바꿔 장애를 무한 반복하지 말고 원인을 확인한다. +FAILED는 source 오류 또는 terminal media 상태다. `STORAGE_UNAVAILABLE`만 정해진 횟수 내에서 +재시도하며 마지막 시도도 실패하면 현재 cursor를 전진시키지 않고 실행을 중단한다. DRY_RUN에서 +`SOURCE_UNREADABLE`이 반복되면 source별 실패로 단정하지 말고 IAM과 암호화 권한부터 확인한다. +DB·설정·불변식 오류도 현재 cursor를 전진시키지 않고 실행을 중단한다. run ID만 바꿔 장애를 +무한 반복하지 말고 원인을 확인한다. ## 7. 검증과 전환 @@ -122,3 +128,17 @@ Docker가 없어서 건너뛴 결과는 검증 완료가 아니다. 실제 AWS dev dry-run → 제한 PREPARE → READY 확인 → 제한 ATTACH → 프로필 응답·전송량 확인은 별도 승인 후 실행한다. 운영 범위·일정 승인, legacy 제거, 원본 삭제는 이 PR에서 수행하지 않는다. + +## 8. 실행 중단과 임시 권한 회수 + +1. 새 실행을 막기 위해 `USER_PROFILE_BACKFILL_ENABLED=false`로 배포한다. 실행 중인 background + task는 정상 종료로 interrupt하고, checkpoint가 `PAUSED`, `COMPLETED` 또는 lease 만료 상태인지 확인한다. +2. 이미 발급된 변환은 `AWS_MEDIA_QUEUE_ENABLED=true`, `AWS_MEDIA_RECOVERY_ENABLED=true`, worker + event source와 result consumer를 유지한 채 처리한다. target PROCESSING, 미완료 EPR, + request/result queue와 두 DLQ가 비었는지 확인하고, 실패 항목은 원인을 분류한 뒤 복구한다. +3. 더 이상 조사·복사·연결이 없으면 `AWS_MEDIA_BACKFILL_ENABLED=false`로 배포한다. +4. SAM `BackfillAccessEnabled=false` change set을 검토·적용해 임시 source 읽기·copy 권한을 회수한다. +5. V23 checkpoint는 실행 이력과 재개 판단을 위해 유지한다. rollback 과정에서 테이블을 삭제하거나 + 과거 migration을 수정하지 않는다. 이미지 pipeline 상태를 모르는 과거 바이너리로 + 되돌려야 한다면 공통 인프라 runbook의 drain 조건을 먼저 만족하고, 조건이 맞지 않으면 현재 계열 + 수정 release를 사용한다. From c59fea2ccd4c7016d126b845faab76aef77a6f41 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sat, 5 Sep 2026 16:30:46 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix(user):=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20backfill=20pause=20lease=20=EB=A7=8C=EB=A3=8C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B3=B4=EC=99=84=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserProfileBackfillCheckpointStore.java | 3 ++ ...ileBackfillPersistenceIntegrationTest.java | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCheckpointStore.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCheckpointStore.java index c6062743..f530d9ed 100644 --- a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCheckpointStore.java +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillCheckpointStore.java @@ -154,6 +154,8 @@ AND lease_until > CURRENT_TIMESTAMP(6) @Transactional public boolean pause(Lease lease) { Objects.requireNonNull(lease, "lease is required"); + // 잠금 대기 중 lease가 만료될 수 있으므로, 잠금을 얻은 뒤 별도 UPDATE 시각으로 다시 판단한다. + findForUpdate(lease.runId()); return jdbcTemplate.update(""" UPDATE user_profile_backfill_checkpoint SET status = 'PAUSED', @@ -164,6 +166,7 @@ public boolean pause(Lease lease) { AND mode = ? AND status = 'RUNNING' AND lease_token = ? + AND lease_until > CURRENT_TIMESTAMP(6) """, lease.runId().toString(), lease.mode().name(), lease.token().toString()) == 1; diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java index db331bf0..59d115c7 100644 --- a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillPersistenceIntegrationTest.java @@ -150,6 +150,19 @@ void setUp() { assertThat(snapshot.attachedCount()).isZero(); } + @Test + void 만료된_lease는_새_worker가_인계하기_전에도_pause할_수_없다() { + UUID runId = UUID.randomUUID(); + Lease lease = acquire(runId, 100L); + expire(lease); + + assertThat(checkpointStore.pause(lease)).isFalse(); + + Snapshot snapshot = checkpointStore.find(runId); + assertThat(snapshot.status()).isEqualTo(UserProfileBackfillCheckpointStore.Status.RUNNING); + assertThat(snapshot.cursorId()).isZero(); + } + @Test void checkpoint_잠금_대기_중_만료된_lease는_갱신하지_않는다() throws Exception { UUID runId = UUID.randomUUID(); @@ -188,6 +201,43 @@ void setUp() { assertThat(checkpointStore.find(runId).cursorId()).isZero(); } + @Test + void checkpoint_잠금_대기_중_만료된_lease는_pause하지_않는다() throws Exception { + UUID runId = UUID.randomUUID(); + Lease lease = checkpointStore.acquire( + runId, UserProfileBackfillMode.ATTACH, 10L, Duration.ofSeconds(3)).lease(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try (Connection connection = DriverManager.getConnection( + MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword())) { + connection.setAutoCommit(false); + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT run_id FROM user_profile_backfill_checkpoint WHERE run_id = ? FOR UPDATE + """)) { + statement.setString(1, runId.toString()); + try (ResultSet ignored = statement.executeQuery()) { + assertThat(ignored.next()).isTrue(); + } + } + Future pause = executor.submit(() -> checkpointStore.pause(lease)); + try { + awaitTableLockWait("user_profile_backfill_checkpoint"); + awaitLeaseExpiry(runId); + } finally { + connection.commit(); + } + + assertThat(pause.get(10, TimeUnit.SECONDS)).isFalse(); + } finally { + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } + + Snapshot snapshot = checkpointStore.find(runId); + assertThat(snapshot.status()).isEqualTo(UserProfileBackfillCheckpointStore.Status.RUNNING); + assertThat(snapshot.cursorId()).isZero(); + } + @Test void 같은_run_ID의_mode는_변경하지_않고_거부_후에도_기록을_보존한다() { UUID runId = UUID.randomUUID(); From f0f2cf9d5361f48f7425fcc135052b993f3b4e4a Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sat, 5 Sep 2026 16:31:45 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix(user):=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20backfill=20=EC=8B=A4=ED=96=89=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EB=B3=B4=EC=99=84=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/UserProfileBackfillRunner.java | 25 ++++++--- .../UserProfileBackfillRunnerTest.java | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java index 77ef4efb..93a7c8af 100644 --- a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java @@ -57,13 +57,7 @@ class UserProfileBackfillRunner { public void runOnStartup() { try { UserProfileBackfillSummary summary = execute(); - log.info( - "User profile backfill finished: mode={}, status={}, " - + "scanned={}, inspected={}, prepared={}, attached={}, skipped={}, failed={}", - summary.mode(), summary.status(), summary.scannedCount(), - summary.inspectedCount(), summary.preparedCount(), summary.attachedCount(), - summary.skippedCount(), summary.failedCount() - ); + logSummary(summary); } catch (RuntimeException exception) { log.error( "User profile backfill could not start: mode={}, errorType={}", @@ -72,6 +66,23 @@ public void runOnStartup() { } } + private void logSummary(UserProfileBackfillSummary summary) { + if (summary.mode() == UserProfileBackfillMode.DRY_RUN) { + log.info( + "User profile backfill finished: mode={}, status={}, scanned={}, inspected={}, failed={}", + summary.mode(), summary.status(), summary.scannedCount(), + summary.inspectedCount(), summary.failedCount() + ); + return; + } + log.info( + "User profile backfill finished: mode={}, status={}, " + + "scanned={}, prepared={}, attached={}, skipped={}, failed={}", + summary.mode(), summary.status(), summary.scannedCount(), summary.preparedCount(), + summary.attachedCount(), summary.skippedCount(), summary.failedCount() + ); + } + UserProfileBackfillSummary execute() { if (properties.mode() == UserProfileBackfillMode.DRY_RUN) { return executeDryRun(); diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java index e2d6a55d..3f451f80 100644 --- a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java @@ -13,6 +13,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -21,6 +24,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; import org.sopt.hashi.media.MediaAssetPurpose; import org.sopt.hashi.media.MediaBackfillAssetInfo; import org.sopt.hashi.media.MediaBackfillAssetInfo.State; @@ -264,6 +268,53 @@ class UserProfileBackfillRunnerTest { verify(mediaBackfillPort).inspect(any()); } + @Test + void DRY_RUN_종료_로그는_실제_inspected_집계를_포함한다() { + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.DRY_RUN, "", 1, 1, 1); + given(candidateReader.findUpperBound()).willReturn(1L); + given(candidateReader.findBatch(0L, 1L, 1)).willReturn(List.of(candidate(1L))); + given(mediaBackfillPort.inspect(any())).willReturn(unpreparedInspection()); + + ILoggingEvent event = runAndCapture(properties); + + assertThat(event.getFormattedMessage()) + .contains("mode=DRY_RUN", "scanned=1", "inspected=1", "failed=0"); + } + + @Test + void 영속_실행_종료_로그는_복구할_수_없는_inspected_집계를_출력하지_않는다() { + UUID runId = UUID.randomUUID(); + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.PREPARE, runId.toString(), 1, 1, 1); + Snapshot completed = snapshot( + runId, properties.mode(), Status.COMPLETED, 1L, 1L, 1, 1, 0, 0, 0); + given(candidateReader.findUpperBound()).willReturn(1L); + given(checkpointStore.acquire(eq(runId), eq(properties.mode()), eq(1L), any())) + .willReturn(new Acquisition(AcquisitionState.COMPLETED, null, completed)); + + ILoggingEvent event = runAndCapture(properties); + + assertThat(event.getFormattedMessage()) + .contains("mode=PREPARE", "scanned=1", "prepared=1", "failed=0") + .doesNotContain("inspected="); + } + + private ILoggingEvent runAndCapture(UserProfileBackfillProperties properties) { + Logger logger = (Logger) LoggerFactory.getLogger(UserProfileBackfillRunner.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + runner(properties).runOnStartup(); + assertThat(appender.list).hasSize(1); + return appender.list.getFirst(); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + private UserProfileBackfillRunner runner(UserProfileBackfillProperties properties) { return new UserProfileBackfillRunner( properties, candidateReader, checkpointStore, attachmentService, mediaBackfillPort); From 807d40c42d9604a1d3daa37e67ef6cfeebae5557 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 20 Sep 2026 01:09:27 +0900 Subject: [PATCH 10/16] =?UTF-8?q?refactor(shared):=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=88=9C=ED=9A=8C=EC=99=80=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EA=B3=B5=ED=86=B5=ED=99=94=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/architecture.md | 7 +- .../shared/migration/BoundedKeysetLoop.java | 68 +++++++ .../hashi/shared/migration/BoundedRetry.java | 69 +++++++ .../migration/BoundedKeysetLoopTest.java | 169 ++++++++++++++++++ .../shared/migration/BoundedRetryTest.java | 128 +++++++++++++ .../migration/MigrationBoundaryTest.java | 20 +++ 6 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/sopt/hashi/shared/migration/BoundedKeysetLoop.java create mode 100644 src/main/java/org/sopt/hashi/shared/migration/BoundedRetry.java create mode 100644 src/test/java/org/sopt/hashi/shared/migration/BoundedKeysetLoopTest.java create mode 100644 src/test/java/org/sopt/hashi/shared/migration/BoundedRetryTest.java create mode 100644 src/test/java/org/sopt/hashi/shared/migration/MigrationBoundaryTest.java diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md index 5716fb23..dd11711c 100644 --- a/docs/conventions/architecture.md +++ b/docs/conventions/architecture.md @@ -92,7 +92,12 @@ - 허용: 응답 래퍼(`BaseResponse`/`SuccessResponse`/`ErrorResponse`), 코드 계약 인터페이스(`BaseCode`/`ErrorCode`/`SuccessCode`), 공통 예외(`BusinessException`), 전역 핸들러(`GlobalExceptionHandler`), 도메인 무관 VO(`Money`/`Address`), 스토리지 포트(`FileStorage`). - **MUST NOT**: 특정 도메인을 아는 타입(예: `RestaurantDto`, `User`, `ReservationStatus`)을 `shared`에 두지 않는다. - **MUST**: 의존 방향은 **도메인 → shared 단방향**. `shared`는 어떤 도메인 모듈도 import하지 않는다. -- 하위 패키지: `response` · `error` · `exception` · `storage` · `swagger` · `vo` +- 하위 패키지: `response` · `error` · `exception` · `storage` · `swagger` · `vo` · `migration` +- **MAY**: `shared/migration`에는 한시적 전환 실행기에서 사용하는, 상태 없는 keyset 반복과 제한 재시도 + 도구만 둔다. 후보 읽기·항목 처리·재시도 대상 판단은 호출자가 전달한다. +- **MUST NOT**: 공통 전환 도구가 콘텐츠 또는 media 타입, Spring Bean, DB·S3 접근, 트랜잭션, + checkpoint·lease 저장이나 도메인 상태 전이를 소유하지 않는다. 이미지 연결과 진행 기록의 원자성은 + 각 소유 모듈이 유지한다. 전환 실행기를 제거할 때 이 도구의 남은 사용처도 함께 확인한다. 원칙: **"틀은 공유, 내용은 도메인."** diff --git a/src/main/java/org/sopt/hashi/shared/migration/BoundedKeysetLoop.java b/src/main/java/org/sopt/hashi/shared/migration/BoundedKeysetLoop.java new file mode 100644 index 00000000..cc6719b3 --- /dev/null +++ b/src/main/java/org/sopt/hashi/shared/migration/BoundedKeysetLoop.java @@ -0,0 +1,68 @@ +package org.sopt.hashi.shared.migration; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.ToLongFunction; + +/** 고정된 ID 상한 안에서 제한된 batch만 순회한다. 영속 상태와 트랜잭션은 호출자가 관리한다. */ +public final class BoundedKeysetLoop { + + private BoundedKeysetLoop() { + } + + public enum Result { + EXHAUSTED, BATCH_LIMIT + } + + @FunctionalInterface + public interface BatchReader { + List read(long cursor, long upperBound, int limit); + } + + public static Result run( + long startCursor, + long upperBound, + int batchSize, + int maxBatches, + BatchReader reader, + ToLongFunction id, + Consumer processor + ) { + if (startCursor < 0 || upperBound < startCursor || batchSize < 1 || maxBatches < 1) { + throw new IllegalArgumentException("invalid bounded keyset range or batch limit"); + } + Objects.requireNonNull(reader, "reader is required"); + Objects.requireNonNull(id, "id is required"); + Objects.requireNonNull(processor, "processor is required"); + long cursor = startCursor; + for (int batchNumber = 0; batchNumber < maxBatches; batchNumber++) { + requireNotInterrupted(); + List candidates = reader.read(cursor, upperBound, batchSize); + if (candidates.isEmpty()) { + return Result.EXHAUSTED; + } + for (T candidate : candidates) { + requireNotInterrupted(); + long nextCursor = id.applyAsLong(candidate); + if (nextCursor <= cursor || nextCursor > upperBound) { + throw new IllegalArgumentException("candidate is outside the keyset range"); + } + processor.accept(candidate); + // 처리와 호출자 측 영속 cursor 기록이 성공한 뒤에만 메모리 cursor를 전진한다. + cursor = nextCursor; + } + if (candidates.size() < batchSize) { + return Result.EXHAUSTED; + } + } + requireNotInterrupted(); + return reader.read(cursor, upperBound, 1).isEmpty() ? Result.EXHAUSTED : Result.BATCH_LIMIT; + } + + private static void requireNotInterrupted() { + if (Thread.currentThread().isInterrupted()) { + throw new IllegalStateException("bounded keyset execution was interrupted"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/shared/migration/BoundedRetry.java b/src/main/java/org/sopt/hashi/shared/migration/BoundedRetry.java new file mode 100644 index 00000000..23eaa029 --- /dev/null +++ b/src/main/java/org/sopt/hashi/shared/migration/BoundedRetry.java @@ -0,0 +1,69 @@ +package org.sopt.hashi.shared.migration; + +import java.time.Duration; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** 호출자가 재시도 가능한 오류를 결정하며, 이 도구는 횟수와 지수 대기만 관리한다. */ +public final class BoundedRetry { + + private BoundedRetry() { + } + + public static T execute( + int maxAttempts, + Duration initialDelay, + Supplier operation, + Predicate retryable + ) { + return execute(maxAttempts, initialDelay, operation, retryable, Thread::sleep); + } + + static T execute( + int maxAttempts, + Duration initialDelay, + Supplier operation, + Predicate retryable, + Sleeper sleeper + ) { + Objects.requireNonNull(initialDelay, "initialDelay is required"); + Objects.requireNonNull(operation, "operation is required"); + Objects.requireNonNull(retryable, "retryable is required"); + Objects.requireNonNull(sleeper, "sleeper is required"); + if (maxAttempts < 1 || initialDelay.isNegative()) { + throw new IllegalArgumentException("attempts must be positive and delay must not be negative"); + } + Duration delay = initialDelay; + for (int attempt = 1; ; attempt++) { + requireNotInterrupted(); + try { + return operation.get(); + } catch (RuntimeException failure) { + if (attempt >= maxAttempts || !retryable.test(failure)) { + throw failure; + } + try { + sleeper.sleep(delay); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("bounded retry was interrupted", interrupted); + } + if (attempt < maxAttempts - 1) { + delay = delay.multipliedBy(2); + } + } + } + } + + private static void requireNotInterrupted() { + if (Thread.currentThread().isInterrupted()) { + throw new IllegalStateException("bounded retry was interrupted"); + } + } + + @FunctionalInterface + interface Sleeper { + void sleep(Duration duration) throws InterruptedException; + } +} diff --git a/src/test/java/org/sopt/hashi/shared/migration/BoundedKeysetLoopTest.java b/src/test/java/org/sopt/hashi/shared/migration/BoundedKeysetLoopTest.java new file mode 100644 index 00000000..e222035c --- /dev/null +++ b/src/test/java/org/sopt/hashi/shared/migration/BoundedKeysetLoopTest.java @@ -0,0 +1,169 @@ +package org.sopt.hashi.shared.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class BoundedKeysetLoopTest { + + @AfterEach + void clearInterrupt() { + Thread.interrupted(); + } + + @Test + void 기존_cursor부터_순회하고_성공한_항목_다음에서_조회한다() { + List calls = new ArrayList<>(); + BoundedKeysetLoop.Result result = BoundedKeysetLoop.run( + 5, 10, 2, 3, + (cursor, upper, limit) -> { + calls.add("read:" + cursor + ":" + upper + ":" + limit); + return cursor == 5 ? List.of(6L, 8L) : List.of(10L); + }, Long::longValue, + id -> calls.add("process:" + id)); + + assertThat(result).isEqualTo(BoundedKeysetLoop.Result.EXHAUSTED); + assertThat(calls).containsExactly( + "read:5:10:2", "process:6", "process:8", "read:8:10:2", "process:10"); + } + + @Test + void 빈_batch는_항목_처리없이_완료한다() { + List processed = new ArrayList<>(); + BoundedKeysetLoop.Result result = BoundedKeysetLoop.run( + 0, 0, 2, 2, (cursor, upper, limit) -> List.of(), + Long::longValue, processed::add); + + assertThat(result).isEqualTo(BoundedKeysetLoop.Result.EXHAUSTED); + assertThat(processed).isEmpty(); + } + + @Test + void 마지막_batch가_가득차도_한건_추가조회가_비었으면_완료한다() { + List reads = new ArrayList<>(); + List processed = new ArrayList<>(); + BoundedKeysetLoop.Result result = BoundedKeysetLoop.run( + 0, 2, 2, 1, + (cursor, upper, limit) -> { + reads.add(cursor + ":" + limit); + return cursor == 0 ? List.of(1L, 2L) : List.of(); + }, Long::longValue, processed::add); + + assertThat(result).isEqualTo(BoundedKeysetLoop.Result.EXHAUSTED); + assertThat(reads).containsExactly("0:2", "2:1"); + assertThat(processed).containsExactly(1L, 2L); + } + + @Test + void 최대_batch_후_남은_항목은_처리하지_않고_한도도달로_반환한다() { + List processed = new ArrayList<>(); + BoundedKeysetLoop.Result result = BoundedKeysetLoop.run( + 0, 3, 2, 1, + (cursor, upper, limit) -> cursor == 0 ? List.of(1L, 2L) : List.of(3L), + Long::longValue, processed::add); + + assertThat(result).isEqualTo(BoundedKeysetLoop.Result.BATCH_LIMIT); + assertThat(processed).containsExactly(1L, 2L); + } + + @Test + void 처리_예외는_그대로_전달하고_후속_항목이나_batch를_진행하지_않는다() { + RuntimeException failure = new IllegalStateException("transaction failed"); + List processed = new ArrayList<>(); + List cursors = new ArrayList<>(); + + assertThatThrownBy(() -> BoundedKeysetLoop.run( + 0, 3, 3, 2, + (cursor, upper, limit) -> { + cursors.add(cursor); + return List.of(1L, 2L, 3L); + }, Long::longValue, id -> { + processed.add(id); + if (id == 2) { + throw failure; + } + })).isSameAs(failure); + + assertThat(processed).containsExactly(1L, 2L); + assertThat(cursors).containsExactly(0L); + } + + @Test + void 조회_예외도_변환하지_않는다() { + RuntimeException failure = new IllegalArgumentException("reader failed"); + assertThatThrownBy(() -> BoundedKeysetLoop.run( + 0, 3, 2, 1, (cursor, upper, limit) -> { throw failure; }, + Long::longValue, id -> { })).isSameAs(failure); + } + + @Test + void 시작전_interrupt는_조회없이_중단하고_플래그를_유지한다() { + List cursors = new ArrayList<>(); + Thread.currentThread().interrupt(); + + assertThatThrownBy(() -> BoundedKeysetLoop.run( + 0, 1, 1, 1, (cursor, upper, limit) -> { + cursors.add(cursor); + return List.of(1L); + }, Long::longValue, id -> { })).isInstanceOf(IllegalStateException.class); + + assertThat(cursors).isEmpty(); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } + + @Test + void 항목_처리중_interrupt는_다음_항목을_처리하지_않는다() { + List processed = new ArrayList<>(); + assertThatThrownBy(() -> BoundedKeysetLoop.run( + 0, 2, 2, 1, (cursor, upper, limit) -> List.of(1L, 2L), + Long::longValue, id -> { + processed.add(id); + Thread.currentThread().interrupt(); + })).isInstanceOf(IllegalStateException.class); + + assertThat(processed).containsExactly(1L); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } + + @Test + void 마지막_항목후_interrupt는_추가조회를_중단한다() { + List cursors = new ArrayList<>(); + assertThatThrownBy(() -> BoundedKeysetLoop.run( + 0, 1, 1, 1, (cursor, upper, limit) -> { + cursors.add(cursor); + return List.of(1L); + }, Long::longValue, id -> Thread.currentThread().interrupt())) + .isInstanceOf(IllegalStateException.class); + + assertThat(cursors).containsExactly(0L); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } + + @Test + void 잘못된_범위와_횟수는_거부한다() { + assertThatThrownBy(() -> runWithRange(-1, 2, 1, 1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> runWithRange(3, 2, 1, 1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> runWithRange(0, 2, 0, 1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> runWithRange(0, 2, 1, 0)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void cursor_이하나_상한밖_항목은_처리하지_않는다() { + List processed = new ArrayList<>(); + for (long invalidId : List.of(1L, 4L)) { + assertThatThrownBy(() -> BoundedKeysetLoop.run( + 1, 3, 1, 1, (cursor, upper, limit) -> List.of(invalidId), + Long::longValue, processed::add)).isInstanceOf(IllegalArgumentException.class); + } + assertThat(processed).isEmpty(); + } + + private void runWithRange(long cursor, long upper, int batchSize, int maxBatches) { + BoundedKeysetLoop.run(cursor, upper, batchSize, maxBatches, + (from, to, limit) -> List.of(), Long::longValue, id -> { }); + } +} diff --git a/src/test/java/org/sopt/hashi/shared/migration/BoundedRetryTest.java b/src/test/java/org/sopt/hashi/shared/migration/BoundedRetryTest.java new file mode 100644 index 00000000..dba2a622 --- /dev/null +++ b/src/test/java/org/sopt/hashi/shared/migration/BoundedRetryTest.java @@ -0,0 +1,128 @@ +package org.sopt.hashi.shared.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class BoundedRetryTest { + + @AfterEach + void clearInterrupt() { + Thread.interrupted(); + } + + @Test + void 최초_성공이면_기다리지_않는다() { + List delays = new ArrayList<>(); + String value = BoundedRetry.execute(3, Duration.ofMillis(10), () -> "ready", + failure -> true, delays::add); + + assertThat(value).isEqualTo("ready"); + assertThat(delays).isEmpty(); + } + + @Test + void 일시오류만_지수간격으로_기다린_뒤_재시도한다() { + AtomicInteger attempts = new AtomicInteger(); + List delays = new ArrayList<>(); + String value = BoundedRetry.execute(4, Duration.ofMillis(10), () -> { + if (attempts.incrementAndGet() < 4) { + throw new IllegalStateException("transient"); + } + return "ready"; + }, failure -> failure instanceof IllegalStateException, delays::add); + + assertThat(value).isEqualTo("ready"); + assertThat(attempts).hasValue(4); + assertThat(delays).containsExactly( + Duration.ofMillis(10), Duration.ofMillis(20), Duration.ofMillis(40)); + } + + @Test + void 마지막_실패는_기다리지_않고_원래_예외를_전달한다() { + AtomicInteger attempts = new AtomicInteger(); + List delays = new ArrayList<>(); + RuntimeException failure = new IllegalStateException("transient"); + + assertThatThrownBy(() -> BoundedRetry.execute(3, Duration.ZERO, () -> { + attempts.incrementAndGet(); + throw failure; + }, error -> true, delays::add)).isSameAs(failure); + + assertThat(attempts).hasValue(3); + assertThat(delays).containsExactly(Duration.ZERO, Duration.ZERO); + } + + @Test + void 영구오류는_한번만_호출하고_그대로_전달한다() { + AtomicInteger attempts = new AtomicInteger(); + List delays = new ArrayList<>(); + RuntimeException failure = new IllegalArgumentException("permanent"); + + assertThatThrownBy(() -> BoundedRetry.execute(3, Duration.ZERO, () -> { + attempts.incrementAndGet(); + throw failure; + }, error -> false, delays::add)).isSameAs(failure); + + assertThat(attempts).hasValue(1); + assertThat(delays).isEmpty(); + } + + @Test + void Error는_재시도하지_않는다() { + List delays = new ArrayList<>(); + AssertionError failure = new AssertionError("fatal"); + assertThatThrownBy(() -> BoundedRetry.execute(3, Duration.ZERO, + () -> { throw failure; }, error -> true, delays::add)).isSameAs(failure); + assertThat(delays).isEmpty(); + } + + @Test + void 대기중_interrupt는_플래그를_복원하고_재시도하지_않는다() { + AtomicInteger attempts = new AtomicInteger(); + assertThatThrownBy(() -> BoundedRetry.execute(3, Duration.ZERO, () -> { + attempts.incrementAndGet(); + throw new IllegalStateException("transient"); + }, error -> true, delay -> { throw new InterruptedException(); })) + .isInstanceOf(IllegalStateException.class) + .hasCauseInstanceOf(InterruptedException.class); + + assertThat(attempts).hasValue(1); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } + + @Test + void 시작전_interrupt는_작업을_호출하지_않는다() { + AtomicInteger attempts = new AtomicInteger(); + Thread.currentThread().interrupt(); + assertThatThrownBy(() -> BoundedRetry.execute(3, Duration.ZERO, + attempts::incrementAndGet, error -> true, delay -> { })) + .isInstanceOf(IllegalStateException.class); + + assertThat(attempts).hasValue(0); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } + + @Test + void 최대한번이면_재시도_대기없이_실패한다() { + List delays = new ArrayList<>(); + RuntimeException failure = new IllegalStateException("transient"); + assertThatThrownBy(() -> BoundedRetry.execute(1, Duration.ofSeconds(1), + () -> { throw failure; }, error -> true, delays::add)).isSameAs(failure); + assertThat(delays).isEmpty(); + } + + @Test + void 잘못된_횟수와_음수_대기를_거부한다() { + assertThatThrownBy(() -> BoundedRetry.execute(0, Duration.ZERO, () -> "ready", error -> true)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> BoundedRetry.execute(1, Duration.ofMillis(-1), () -> "ready", error -> true)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/org/sopt/hashi/shared/migration/MigrationBoundaryTest.java b/src/test/java/org/sopt/hashi/shared/migration/MigrationBoundaryTest.java new file mode 100644 index 00000000..79b36d93 --- /dev/null +++ b/src/test/java/org/sopt/hashi/shared/migration/MigrationBoundaryTest.java @@ -0,0 +1,20 @@ +package org.sopt.hashi.shared.migration; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +class MigrationBoundaryTest { + + @Test + void 공통_순회와_재시도는_JDK와_자기_패키지만_참조한다() { + classes().that().resideInAPackage("org.sopt.hashi.shared.migration..") + .should().onlyDependOnClassesThat() + .resideInAnyPackage("java..", "org.sopt.hashi.shared.migration..") + .check(new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("org.sopt.hashi.shared.migration")); + } +} From 6ec450dce5a2c652dfb05abec44294e48e7c6696 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 20 Sep 2026 01:13:12 +0900 Subject: [PATCH 11/16] =?UTF-8?q?refactor(restaurant):=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=A0=84=ED=99=98=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=8F=84=EA=B5=AC=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RestaurantMediaBackfillRunner.java | 121 +++++------------- .../RestaurantMediaBackfillRunnerTest.java | 34 +++++ 2 files changed, 66 insertions(+), 89 deletions(-) diff --git a/src/main/java/org/sopt/hashi/restaurant/migration/RestaurantMediaBackfillRunner.java b/src/main/java/org/sopt/hashi/restaurant/migration/RestaurantMediaBackfillRunner.java index 2816f449..1ebe020c 100644 --- a/src/main/java/org/sopt/hashi/restaurant/migration/RestaurantMediaBackfillRunner.java +++ b/src/main/java/org/sopt/hashi/restaurant/migration/RestaurantMediaBackfillRunner.java @@ -1,7 +1,5 @@ package org.sopt.hashi.restaurant.migration; -import java.time.Duration; -import java.util.List; import java.util.Optional; import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; @@ -16,6 +14,8 @@ import org.sopt.hashi.restaurant.migration.RestaurantMediaBackfillCheckpointStore.Lease; import org.sopt.hashi.restaurant.migration.RestaurantMediaBackfillCheckpointStore.Snapshot; import org.sopt.hashi.restaurant.migration.RestaurantMediaBackfillSummary.Status; +import org.sopt.hashi.shared.migration.BoundedKeysetLoop; +import org.sopt.hashi.shared.migration.BoundedRetry; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; @@ -93,38 +93,28 @@ RestaurantMediaBackfillSummary execute() { private RestaurantMediaBackfillSummary executeDryRun() { long upperBound = candidateReader.findUpperBound(properties.target()); - long cursor = 0L; MutableSummary summary = new MutableSummary(properties.target(), properties.mode()); + BoundedKeysetLoop.Result result = BoundedKeysetLoop.run( + 0L, upperBound, properties.batchSize(), properties.maxBatches(), + (cursor, upper, limit) -> candidateReader.findBatch(properties.target(), cursor, upper, limit), + RestaurantMediaBackfillCandidate::associationId, + candidate -> inspectAndCount(candidate, summary)); + return summary.finish(result == BoundedKeysetLoop.Result.EXHAUSTED ? Status.COMPLETED : Status.PAUSED); + } - for (int batchNumber = 0; batchNumber < properties.maxBatches(); batchNumber++) { - requireNotInterrupted(); - List candidates = findBatch(cursor, upperBound); - if (candidates.isEmpty()) { - return summary.finish(Status.COMPLETED); - } - for (RestaurantMediaBackfillCandidate candidate : candidates) { - requireNotInterrupted(); - summary.scanned++; - if (!candidate.hasUsableLegacyKey()) { - summary.failed++; - cursor = candidate.associationId(); - continue; - } - try { - inspect(candidate); - summary.inspected++; - } catch (MediaBackfillSourceException exception) { - rethrowInfrastructureFailure(exception); - summary.failed++; - } - cursor = candidate.associationId(); - } - if (candidates.size() < properties.batchSize()) { - return summary.finish(Status.COMPLETED); - } + private void inspectAndCount(RestaurantMediaBackfillCandidate candidate, MutableSummary summary) { + summary.scanned++; + if (!candidate.hasUsableLegacyKey()) { + summary.failed++; + return; + } + try { + inspect(candidate); + summary.inspected++; + } catch (MediaBackfillSourceException exception) { + rethrowInfrastructureFailure(exception); + summary.failed++; } - Status status = hasMore(cursor, upperBound) ? Status.PAUSED : Status.COMPLETED; - return summary.finish(status); } private RestaurantMediaBackfillSummary executePersistent() { @@ -142,24 +132,14 @@ private RestaurantMediaBackfillSummary executePersistent() { } Lease lease = acquisition.lease(); - long cursor = acquisition.snapshot().cursorId(); try { - for (int batchNumber = 0; batchNumber < properties.maxBatches(); batchNumber++) { - requireNotInterrupted(); - List candidates = findBatch(cursor, lease.upperBoundId()); - if (candidates.isEmpty()) { - return completedSummary(lease); - } - for (RestaurantMediaBackfillCandidate candidate : candidates) { - requireNotInterrupted(); - processAndRecord(candidate, lease); - cursor = candidate.associationId(); - } - if (candidates.size() < properties.batchSize()) { - return completedSummary(lease); - } - } - if (!hasMore(cursor, lease.upperBoundId())) { + BoundedKeysetLoop.Result result = BoundedKeysetLoop.run( + acquisition.snapshot().cursorId(), lease.upperBoundId(), + properties.batchSize(), properties.maxBatches(), + (cursor, upper, limit) -> candidateReader.findBatch(properties.target(), cursor, upper, limit), + RestaurantMediaBackfillCandidate::associationId, + candidate -> processAndRecord(candidate, lease)); + if (result == BoundedKeysetLoop.Result.EXHAUSTED) { return completedSummary(lease); } boolean paused = checkpointStore.pause(lease); @@ -258,19 +238,10 @@ private boolean terminal(MediaBackfillAssetInfo.State state) { } private T withStorageRetry(Supplier operation) { - int attempt = 1; - while (true) { - try { - return operation.get(); - } catch (MediaBackfillSourceException exception) { - boolean retryable = exception.getReason() == Reason.STORAGE_UNAVAILABLE; - if (!retryable || attempt >= properties.maxAttempts()) { - throw exception; - } - sleep(backoff(properties.retryInitialDelay(), attempt)); - attempt++; - } - } + return BoundedRetry.execute( + properties.maxAttempts(), properties.retryInitialDelay(), operation, + failure -> failure instanceof MediaBackfillSourceException source + && source.getReason() == Reason.STORAGE_UNAVAILABLE); } private void rethrowInfrastructureFailure(MediaBackfillSourceException exception) { @@ -286,39 +257,11 @@ private String failureType(RuntimeException exception) { return exception.getClass().getSimpleName(); } - private Duration backoff(Duration initialDelay, int attempt) { - return initialDelay.multipliedBy(1L << (attempt - 1)); - } - - private void sleep(Duration delay) { - try { - Thread.sleep(delay); - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("restaurant media backfill was interrupted"); - } - } - - private void requireNotInterrupted() { - if (Thread.currentThread().isInterrupted()) { - throw new IllegalStateException("restaurant media backfill was interrupted"); - } - } - private MediaBackfillReference reference(RestaurantMediaBackfillCandidate candidate) { return new MediaBackfillReference( candidate.target().mediaTarget(), candidate.associationId(), candidate.legacyKey()); } - private List findBatch(long cursor, long upperBound) { - return candidateReader.findBatch( - properties.target(), cursor, upperBound, properties.batchSize()); - } - - private boolean hasMore(long cursor, long upperBound) { - return !candidateReader.findBatch(properties.target(), cursor, upperBound, 1).isEmpty(); - } - private RestaurantMediaBackfillSummary completedSummary(Lease lease) { Snapshot snapshot = checkpointStore.complete(lease); return RestaurantMediaBackfillSummary.fromSnapshot(Status.COMPLETED, snapshot); diff --git a/src/test/java/org/sopt/hashi/restaurant/migration/RestaurantMediaBackfillRunnerTest.java b/src/test/java/org/sopt/hashi/restaurant/migration/RestaurantMediaBackfillRunnerTest.java index d7e1a88c..88848837 100644 --- a/src/test/java/org/sopt/hashi/restaurant/migration/RestaurantMediaBackfillRunnerTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/migration/RestaurantMediaBackfillRunnerTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -21,6 +22,7 @@ import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.slf4j.LoggerFactory; import org.sopt.hashi.media.MediaAssetPurpose; import org.sopt.hashi.media.MediaBackfillAssetInfo; @@ -47,6 +49,38 @@ class RestaurantMediaBackfillRunnerTest { mock(RestaurantMediaBackfillAttachmentService.class); private final MediaBackfillPort mediaBackfillPort = mock(MediaBackfillPort.class); + @Test + void 저장된_cursor에서_재개하고_마지막_full_batch의_추가조회가_비면_완료한다() { + UUID runId = UUID.randomUUID(); + RestaurantMediaBackfillProperties properties = properties( + RestaurantMediaBackfillMode.PREPARE, runId.toString(), 1, 1, 1); + Lease lease = lease(runId, properties.mode(), 3L); + given(candidateReader.findUpperBound(properties.target())).willReturn(9L); + given(checkpointStore.acquire( + eq(runId), eq(properties.target()), eq(properties.mode()), eq(9L), any())) + .willReturn(new Acquisition(AcquisitionState.ACQUIRED, lease, snapshot( + runId, properties.mode(), Status.RUNNING, 3L, 2L, 2, 2, 0, 0, 0))); + given(candidateReader.findBatch(properties.target(), 2L, 3L, 1)) + .willReturn(List.of(candidate(3L))); + given(candidateReader.findBatch(properties.target(), 3L, 3L, 1)).willReturn(List.of()); + given(mediaBackfillPort.inspect(any())).willReturn(inspection(asset(State.READY))); + given(checkpointStore.complete(lease)).willReturn(snapshot( + runId, properties.mode(), Status.COMPLETED, 3L, 3L, 3, 3, 0, 0, 0)); + + RestaurantMediaBackfillSummary summary = runner(properties).execute(); + + assertThat(summary.status()).isEqualTo(RestaurantMediaBackfillSummary.Status.COMPLETED); + assertThat(summary.preparedCount()).isEqualTo(3); + InOrder ordered = inOrder(candidateReader, checkpointStore); + ordered.verify(candidateReader).findBatch(properties.target(), 2L, 3L, 1); + ordered.verify(checkpointStore).recordProgress( + lease, 3L, RestaurantMediaBackfillOutcome.PREPARED, properties.leaseDuration()); + ordered.verify(candidateReader).findBatch(properties.target(), 3L, 3L, 1); + ordered.verify(checkpointStore).complete(lease); + verify(checkpointStore, never()).pause(any()); + verify(mediaBackfillPort, never()).prepare(any(), any()); + } + @Test void DRY_RUN은_inspect만_수행하고_DB나_asset을_변경하지_않는다() { RestaurantMediaBackfillProperties properties = properties( From 8d96a5113ee9d562e6ddb39b0f5b4df6f29c203f Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 20 Sep 2026 01:13:46 +0900 Subject: [PATCH 12/16] =?UTF-8?q?refactor(user):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=A0=84=ED=99=98=20=EA=B3=B5=ED=86=B5=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EB=8F=84=EA=B5=AC=20=EC=A0=81=EC=9A=A9=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/UserProfileBackfillRunner.java | 121 +++++------------- .../UserProfileBackfillRunnerTest.java | 32 +++++ 2 files changed, 63 insertions(+), 90 deletions(-) diff --git a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java index 93a7c8af..08c24b49 100644 --- a/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java +++ b/src/main/java/org/sopt/hashi/user/migration/UserProfileBackfillRunner.java @@ -1,7 +1,5 @@ package org.sopt.hashi.user.migration; -import java.time.Duration; -import java.util.List; import java.util.Optional; import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; @@ -10,8 +8,10 @@ import org.sopt.hashi.media.MediaBackfillPort; import org.sopt.hashi.media.MediaBackfillReference; import org.sopt.hashi.media.MediaBackfillSourceException; -import org.sopt.hashi.media.MediaBackfillTarget; import org.sopt.hashi.media.MediaBackfillSourceException.Reason; +import org.sopt.hashi.media.MediaBackfillTarget; +import org.sopt.hashi.shared.migration.BoundedKeysetLoop; +import org.sopt.hashi.shared.migration.BoundedRetry; import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Acquisition; import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.AcquisitionState; import org.sopt.hashi.user.migration.UserProfileBackfillCheckpointStore.Lease; @@ -92,38 +92,27 @@ UserProfileBackfillSummary execute() { private UserProfileBackfillSummary executeDryRun() { long upperBound = candidateReader.findUpperBound(); - long cursor = 0L; MutableSummary summary = new MutableSummary(properties.mode()); + BoundedKeysetLoop.Result result = BoundedKeysetLoop.run( + 0L, upperBound, properties.batchSize(), properties.maxBatches(), + candidateReader::findBatch, UserProfileBackfillCandidate::userId, + candidate -> inspectAndCount(candidate, summary)); + return summary.finish(result == BoundedKeysetLoop.Result.EXHAUSTED ? Status.COMPLETED : Status.PAUSED); + } - for (int batchNumber = 0; batchNumber < properties.maxBatches(); batchNumber++) { - requireNotInterrupted(); - List candidates = findBatch(cursor, upperBound); - if (candidates.isEmpty()) { - return summary.finish(Status.COMPLETED); - } - for (UserProfileBackfillCandidate candidate : candidates) { - requireNotInterrupted(); - summary.scanned++; - if (!candidate.hasUsableLegacyKey()) { - summary.failed++; - cursor = candidate.userId(); - continue; - } - try { - inspect(candidate); - summary.inspected++; - } catch (MediaBackfillSourceException exception) { - rethrowInfrastructureFailure(exception); - summary.failed++; - } - cursor = candidate.userId(); - } - if (candidates.size() < properties.batchSize()) { - return summary.finish(Status.COMPLETED); - } + private void inspectAndCount(UserProfileBackfillCandidate candidate, MutableSummary summary) { + summary.scanned++; + if (!candidate.hasUsableLegacyKey()) { + summary.failed++; + return; + } + try { + inspect(candidate); + summary.inspected++; + } catch (MediaBackfillSourceException exception) { + rethrowInfrastructureFailure(exception); + summary.failed++; } - Status status = hasMore(cursor, upperBound) ? Status.PAUSED : Status.COMPLETED; - return summary.finish(status); } private UserProfileBackfillSummary executePersistent() { @@ -141,24 +130,13 @@ private UserProfileBackfillSummary executePersistent() { } Lease lease = acquisition.lease(); - long cursor = acquisition.snapshot().cursorId(); try { - for (int batchNumber = 0; batchNumber < properties.maxBatches(); batchNumber++) { - requireNotInterrupted(); - List candidates = findBatch(cursor, lease.upperBoundId()); - if (candidates.isEmpty()) { - return completedSummary(lease); - } - for (UserProfileBackfillCandidate candidate : candidates) { - requireNotInterrupted(); - processAndRecord(candidate, lease); - cursor = candidate.userId(); - } - if (candidates.size() < properties.batchSize()) { - return completedSummary(lease); - } - } - if (!hasMore(cursor, lease.upperBoundId())) { + BoundedKeysetLoop.Result result = BoundedKeysetLoop.run( + acquisition.snapshot().cursorId(), lease.upperBoundId(), + properties.batchSize(), properties.maxBatches(), + candidateReader::findBatch, UserProfileBackfillCandidate::userId, + candidate -> processAndRecord(candidate, lease)); + if (result == BoundedKeysetLoop.Result.EXHAUSTED) { return completedSummary(lease); } boolean paused = checkpointStore.pause(lease); @@ -256,19 +234,10 @@ private boolean terminal(MediaBackfillAssetInfo.State state) { } private T withStorageRetry(Supplier operation) { - int attempt = 1; - while (true) { - try { - return operation.get(); - } catch (MediaBackfillSourceException exception) { - boolean retryable = exception.getReason() == Reason.STORAGE_UNAVAILABLE; - if (!retryable || attempt >= properties.maxAttempts()) { - throw exception; - } - sleep(backoff(properties.retryInitialDelay(), attempt)); - attempt++; - } - } + return BoundedRetry.execute( + properties.maxAttempts(), properties.retryInitialDelay(), operation, + failure -> failure instanceof MediaBackfillSourceException source + && source.getReason() == Reason.STORAGE_UNAVAILABLE); } private void rethrowInfrastructureFailure(MediaBackfillSourceException exception) { @@ -284,39 +253,11 @@ private String failureType(RuntimeException exception) { return exception.getClass().getSimpleName(); } - private Duration backoff(Duration initialDelay, int attempt) { - return initialDelay.multipliedBy(1L << (attempt - 1)); - } - - private void sleep(Duration delay) { - try { - Thread.sleep(delay); - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("user profile backfill was interrupted"); - } - } - - private void requireNotInterrupted() { - if (Thread.currentThread().isInterrupted()) { - throw new IllegalStateException("user profile backfill was interrupted"); - } - } - private MediaBackfillReference reference(UserProfileBackfillCandidate candidate) { return new MediaBackfillReference( MediaBackfillTarget.USER_PROFILE, candidate.userId(), candidate.legacyKey()); } - private List findBatch(long cursor, long upperBound) { - return candidateReader.findBatch( - cursor, upperBound, properties.batchSize()); - } - - private boolean hasMore(long cursor, long upperBound) { - return !candidateReader.findBatch(cursor, upperBound, 1).isEmpty(); - } - private UserProfileBackfillSummary completedSummary(Lease lease) { Snapshot snapshot = checkpointStore.complete(lease); return UserProfileBackfillSummary.fromSnapshot(Status.COMPLETED, snapshot); diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java index 3f451f80..40f86199 100644 --- a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillRunnerTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -24,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InOrder; import org.slf4j.LoggerFactory; import org.sopt.hashi.media.MediaAssetPurpose; import org.sopt.hashi.media.MediaBackfillAssetInfo; @@ -50,6 +52,36 @@ class UserProfileBackfillRunnerTest { mock(UserProfileBackfillAttachmentService.class); private final MediaBackfillPort mediaBackfillPort = mock(MediaBackfillPort.class); + @Test + void 저장된_cursor에서_재개하고_마지막_full_batch의_추가조회가_비면_완료한다() { + UUID runId = UUID.randomUUID(); + UserProfileBackfillProperties properties = properties( + UserProfileBackfillMode.PREPARE, runId.toString(), 1, 1, 1); + Lease lease = lease(runId, properties.mode(), 3L); + given(candidateReader.findUpperBound()).willReturn(9L); + given(checkpointStore.acquire(eq(runId), eq(properties.mode()), eq(9L), any())) + .willReturn(new Acquisition(AcquisitionState.ACQUIRED, lease, snapshot( + runId, properties.mode(), Status.RUNNING, 3L, 2L, 2, 2, 0, 0, 0))); + given(candidateReader.findBatch(2L, 3L, 1)).willReturn(List.of(candidate(3L))); + given(candidateReader.findBatch(3L, 3L, 1)).willReturn(List.of()); + given(mediaBackfillPort.inspect(any())).willReturn(inspection(asset(State.READY))); + given(checkpointStore.complete(lease)).willReturn(snapshot( + runId, properties.mode(), Status.COMPLETED, 3L, 3L, 3, 3, 0, 0, 0)); + + UserProfileBackfillSummary summary = runner(properties).execute(); + + assertThat(summary.status()).isEqualTo(UserProfileBackfillSummary.Status.COMPLETED); + assertThat(summary.preparedCount()).isEqualTo(3); + InOrder ordered = inOrder(candidateReader, checkpointStore); + ordered.verify(candidateReader).findBatch(2L, 3L, 1); + ordered.verify(checkpointStore).recordProgress( + lease, 3L, UserProfileBackfillOutcome.PREPARED, properties.leaseDuration()); + ordered.verify(candidateReader).findBatch(3L, 3L, 1); + ordered.verify(checkpointStore).complete(lease); + verify(checkpointStore, never()).pause(any()); + verify(mediaBackfillPort, never()).prepare(any(), any()); + } + @Test void DRY_RUN은_inspect만_수행하고_DB나_asset을_변경하지_않는다() { UserProfileBackfillProperties properties = properties( From c99efb7a4182feb9cd753fb47461bcaabfa51822 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 20 Sep 2026 01:13:49 +0900 Subject: [PATCH 13/16] =?UTF-8?q?ci:=20=EA=B3=B5=ED=86=B5=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=8F=84=EA=B5=AC=20=EB=B3=80=EA=B2=BD=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-restaurant-media-backfill.yml | 2 ++ .../user/migration/UserProfileBackfillWorkflowTest.java | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/ci-restaurant-media-backfill.yml b/.github/workflows/ci-restaurant-media-backfill.yml index 1f38426f..9a841559 100644 --- a/.github/workflows/ci-restaurant-media-backfill.yml +++ b/.github/workflows/ci-restaurant-media-backfill.yml @@ -8,6 +8,8 @@ on: - "src/test/java/org/sopt/hashi/restaurant/**" - "src/main/java/org/sopt/hashi/user/**" - "src/test/java/org/sopt/hashi/user/**" + - "src/main/java/org/sopt/hashi/shared/migration/**" + - "src/test/java/org/sopt/hashi/shared/migration/**" - "src/main/resources/db/migration/**" - "src/main/resources/application.yml" - "docs/media/restaurant-menu-backfill-runbook.md" diff --git a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillWorkflowTest.java b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillWorkflowTest.java index 257e2640..f4d10777 100644 --- a/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillWorkflowTest.java +++ b/src/test/java/org/sopt/hashi/user/migration/UserProfileBackfillWorkflowTest.java @@ -33,6 +33,15 @@ class UserProfileBackfillWorkflowTest { assertThat(permissions).hasSize(1); assertThat(permissions.get("contents")).isEqualTo("read"); + // SnakeYAML의 YAML 1.1 해석에서는 on이 Boolean.TRUE 키가 될 수 있다. + Map triggers = (Map) workflow.get("on"); + if (triggers == null) { + triggers = (Map) workflow.get(Boolean.TRUE); + } + Map pullRequest = (Map) triggers.get("pull_request"); + List paths = (List) pullRequest.get("paths"); + assertThat(paths).anyMatch("src/main/java/org/sopt/hashi/shared/migration/**"::equals); + assertThat(paths).anyMatch("src/test/java/org/sopt/hashi/shared/migration/**"::equals); assertThat((String) checkout.get("uses")).matches("actions/checkout@[0-9a-f]{40}"); assertThat(checkoutInputs.get("ref")).isEqualTo("${{ github.event.pull_request.head.sha }}"); assertThat(commands).contains("docker info", "bash ./gradlew clean build --no-daemon"); From 02ced5f6f731b7ac2241ab025b2709d74d002665 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 20 Sep 2026 01:13:52 +0900 Subject: [PATCH 14/16] =?UTF-8?q?docs(media):=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=8F=84=EA=B5=AC=20=EC=B1=85=EC=9E=84=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=A0=95=EB=A6=AC=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/media/legacy-backfill-runbook.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/media/legacy-backfill-runbook.md b/docs/media/legacy-backfill-runbook.md index 79b2529e..233bdc53 100644 --- a/docs/media/legacy-backfill-runbook.md +++ b/docs/media/legacy-backfill-runbook.md @@ -154,6 +154,10 @@ CloudFront 전달 E2E가 완료됐다고 보고하지 않는다. 배포 전에 IAM과 전체 변환·연결 흐름을 별도 검증해야 한다. 식당·메뉴와 프로필 runner의 keyset batch·checkpoint·dry-run과 동시 수정 검증은 별도 실행기 문서를 따른다. +두 runner의 배치 순회와 제한 재시도는 `shared/migration`의 도메인 무관 도구를 사용한다. +항목 처리가 정상 반환한 뒤에만 메모리 cursor를 전진시키며, 처리 예외는 호출자에게 그대로 전달한다. +후보 선정, source 오류 분류, lease·checkpoint, 완료·중단 집계와 연결 transaction은 각 모듈에 남긴다. +DB 테이블이나 migration을 합치지 않는다. 매거진 실행기의 공통 도구 적용은 후속 PR에서 검증한다. 후속 작업은 매거진 runner, 안전한 cleanup/reconciliation, dev E2E와 운영 승인이다. legacy 필드 제거와 원본 삭제는 별도 종료 조건과 승인을 충족하기 전에는 실행하지 않는다. From a57313c63d2cbe7ce4399fa17841b13eafe4fed2 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 20 Sep 2026 02:00:09 +0900 Subject: [PATCH 15/16] =?UTF-8?q?test(media):=20MySQL=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=88=9C=EC=84=9C=20=EC=A0=95=EB=A6=AC=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/hashi/media/service/MediaPortIntegrationTest.java | 2 ++ .../service/MediaTransformResultServiceIntegrationTest.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java index a4367eb2..dc852960 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java @@ -48,6 +48,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.IllegalTransactionStateException; import org.springframework.transaction.support.TransactionTemplate; @@ -64,6 +65,7 @@ "kakao.redirect-uri=https://app.hashi.test/callback", "hashi.storage.cloudfront-domain=https://cdn.hashi.test" }) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class MediaPortIntegrationTest { private static final String SPEC_DIGEST = diff --git a/src/test/java/org/sopt/hashi/media/service/MediaTransformResultServiceIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/MediaTransformResultServiceIntegrationTest.java index 71511a1b..6a998b9b 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaTransformResultServiceIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaTransformResultServiceIntegrationTest.java @@ -50,6 +50,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -62,6 +63,7 @@ "kakao.redirect-uri=https://app.hashi.test/callback", "hashi.storage.cloudfront-domain=https://cdn.hashi.test" }) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class MediaTransformResultServiceIntegrationTest { private static final String SPEC_DIGEST = From 52b534f751887b57d8d7da06fa8ec70e9759ca7f Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 20 Sep 2026 02:00:12 +0900 Subject: [PATCH 16/16] =?UTF-8?q?test(restaurant):=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BB=A8=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A2=85=EB=A3=8C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../restaurant/domain/RestaurantMediaSchemaValidationTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java index 9fd8d106..ea3dc59a 100644 --- a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java @@ -16,6 +16,7 @@ import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.transaction.annotation.Transactional; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; @@ -30,6 +31,7 @@ "hashi.storage.cloudfront-domain=https://cdn.hashi.test" }) @Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class RestaurantMediaSchemaValidationTest { @Container