CatalogSearchRebuildCutoverService.java
package com.ecommerce.catalog.application.service;
import com.ecommerce.catalog.application.port.ProductSearchIndex;
import com.ecommerce.catalog.infrastructure.persistence.entity.SearchRebuildEntity;
import com.ecommerce.catalog.infrastructure.persistence.mapper.SearchRebuildMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Objects;
@Service
public class CatalogSearchRebuildCutoverService {
private final SearchRebuildMapper mapper;
private final ProductSearchIndex index;
public CatalogSearchRebuildCutoverService(
SearchRebuildMapper mapper,
ProductSearchIndex index) {
this.mapper = mapper;
this.index = index;
}
@Transactional
public boolean cutover(
long rebuildId,
String owner,
String workingIndex,
long indexedCount) {
Instant lockedAt = mapper.currentTime();
SearchRebuildEntity rebuild = mapper.selectByIdForUpdate(rebuildId);
if (rebuild == null
|| !"RUNNING".equals(rebuild.getStatus())
|| !Objects.equals(owner, rebuild.getClaimOwner())
|| !Objects.equals(workingIndex, rebuild.getTargetIndex())
|| rebuild.getClaimUntil() == null
|| !rebuild.getClaimUntil().isAfter(lockedAt)) {
return false;
}
// Alias replacement is intentionally kept inside this short row-lock transaction.
// It prevents a stale-lease reset or a new claimant from crossing the cutover point.
replaceAliasAndResolveUnknownResult(workingIndex);
if (mapper.markSucceeded(
rebuildId,
owner,
workingIndex,
indexedCount,
lockedAt) != 1) {
throw new IllegalStateException("Search rebuild completion state changed during cutover");
}
return true;
}
private void replaceAliasAndResolveUnknownResult(String workingIndex) {
try {
index.replaceAlias(workingIndex);
} catch (RuntimeException switchFailure) {
try {
if (index.aliasTargets(workingIndex)) {
return;
}
} catch (RuntimeException verificationFailure) {
switchFailure.addSuppressed(verificationFailure);
}
throw switchFailure;
}
}
}