Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,7 @@ exp*
### technical observation tests ###
**/src/test/java/**/technical/
**/src/test/java/**/benchmark
**/src/test/java/**/verification
**/src/test/java/**/verification

### local performance tests (k6 등 체크리스트 외 성능 테스트) ###
k6/local/
1 change: 1 addition & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ dependencies {
implementation(project(":supports:jackson"))
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))
implementation(project(":supports:ranking"))

// web
implementation("org.springframework.boot:spring-boot-starter-web")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.loopers.application.ranking;

import java.sql.Timestamp;

record MvRankPublicationRow(
String periodType,
String periodKey,
long publishedVersion,
long nextVersion,
Timestamp updatedAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.loopers.application.ranking;

import com.loopers.domain.ranking.RankPeriodType;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;

import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;

@Component
@RequiredArgsConstructor
public class MvRankStatusApp {

private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");

private static final String SELECT_PUBLICATION_BASE =
"SELECT period_type, period_key, published_version, next_version, updated_at " +
"FROM mv_product_rank_publication WHERE period_type = ?";

private static final RowMapper<MvRankPublicationRow> PUBLICATION_MAPPER = (rs, n) -> new MvRankPublicationRow(
rs.getString("period_type"),
rs.getString("period_key"),
rs.getLong("published_version"),
rs.getLong("next_version"),
rs.getTimestamp("updated_at")
);

private final JdbcTemplate jdbcTemplate;

public List<MvRankStatusInfo> listStatuses(RankPeriodType type, int offset, int size) {
return jdbcTemplate.query(
SELECT_PUBLICATION_BASE + " ORDER BY period_key DESC LIMIT ? OFFSET ?",
PUBLICATION_MAPPER, type.name(), size, offset
).stream()
.map(r -> buildStatus(type, r))
.toList();
}

public MvRankStatusInfo getStatus(RankPeriodType type, String periodKey) {
return jdbcTemplate.query(
SELECT_PUBLICATION_BASE + " AND period_key = ?",
PUBLICATION_MAPPER, type.name(), periodKey
).stream().findFirst()
.map(r -> buildStatus(type, r))
.orElseGet(() -> new MvRankStatusInfo(type.name(), periodKey, 0L, 0L, null, 0L, 0L, 0L, List.of()));
}

private MvRankStatusInfo buildStatus(RankPeriodType type, MvRankPublicationRow row) {
List<MvRankVersionCount> breakdown = jdbcTemplate.query(
"SELECT version, COUNT(*) AS cnt FROM " + type.getTableName() +
" WHERE period_key = ? GROUP BY version ORDER BY version DESC",
(rs, n) -> new MvRankVersionCount(rs.getLong("version"), rs.getLong("cnt")),
row.periodKey()
);
long total = breakdown.stream().mapToLong(MvRankVersionCount::rowCount).sum();
long publishedRows = breakdown.stream()
.filter(b -> b.version() == row.publishedVersion())
.mapToLong(MvRankVersionCount::rowCount)
.sum();
ZonedDateTime ts = row.updatedAt() == null ? null : row.updatedAt().toInstant().atZone(SEOUL);
return new MvRankStatusInfo(row.periodType(), row.periodKey(),
row.publishedVersion(), row.nextVersion(), ts,
total, publishedRows, total - publishedRows, breakdown);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.loopers.application.ranking;

import java.time.ZonedDateTime;
import java.util.List;

public record MvRankStatusInfo(
String periodType,
String periodKey,
long publishedVersion,
long nextVersion,
ZonedDateTime updatedAt,
long totalRowCount,
long publishedRowCount,
long orphanRowCount,
List<MvRankVersionCount> versionBreakdown
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.loopers.application.ranking;

public record MvRankVersionCount(long version, long rowCount) {
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,53 @@
package com.loopers.application.ranking;

import com.loopers.domain.ranking.MvProductRankRepository;
import com.loopers.domain.ranking.RankPeriodType;
import com.loopers.domain.ranking.RankingEntry;
import com.loopers.domain.ranking.RankingPeriod;
import com.loopers.domain.ranking.RankingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiFunction;

@Component
@RequiredArgsConstructor
public class RankingApp {

private final RankingRepository rankingRepository;
private final MvProductRankRepository mvProductRankRepository;
private final RankingProductCache productCache;

public RankingPageResult getTopN(LocalDate date, long page, long size) {
@Value("${ranking.cold-start-fallback.enabled:false}")
private boolean coldStartFallbackEnabled;

public RankingPageResult getTopN(RankingPeriod period, LocalDate date, long page, long size) {
long offset = page * size;
List<RankingEntry> entries = rankingRepository.findTopN(date, offset, size);
long totalElements = rankingRepository.countMembers(date);
List<RankingInfo> items = enrich(entries, offset);
return new RankingPageResult(items, page, size, totalElements);
if (!period.isMvBased()) {
return getDailyTopN(date, page, size, offset);
}
RankPeriodType type = period.toMvType();
RankingPageResult primary = queryMvPeriod(type, period.periodKey(date, false), page, size, offset, false);
if (primary.totalElements() > 0 || !coldStartFallbackEnabled) {
return primary;
}
return queryMvPeriod(type, period.periodKey(date, true), page, size, offset, true);
}

public RankingPageResult getTopN(LocalDate date, long page, long size) {
return getTopN(RankingPeriod.DAILY, date, page, size);
}

public RankingCursorResult getByCursor(LocalDate date, Double cursorScore, long size) {
List<RankingEntry> entries = rankingRepository.findByCursor(date, cursorScore, size);
List<RankingInfo> items = enrichByCursor(date, entries);
List<RankingInfo> items = enrichWith(entries,
(i, e) -> rankingRepository.findRank(date, e.productDbId()).orElse(0L));
Double nextCursor = items.isEmpty() ? null : entries.get(entries.size() - 1).score();
return new RankingCursorResult(items, nextCursor);
}
Expand All @@ -41,81 +61,63 @@ public Optional<ProductRankingInfo> getProductRanking(Long productDbId, LocalDat
return Optional.of(new ProductRankingInfo(rank.get(), score));
}

private List<RankingInfo> enrich(List<RankingEntry> entries, long baseOffset) {
if (entries.isEmpty()) {
return List.of();
}
List<RankingInfo> items = new ArrayList<>(entries.size());
long rank = baseOffset;
for (RankingEntry entry : entries) {
CachedProductSnapshot snapshot = productCache.findById(entry.productDbId());
if (snapshot == null || snapshot.deleted()) {
continue;
}
rank++;
items.add(toInfo(entry, rank, snapshot));
}
return items;
}

private List<RankingInfo> enrichByCursor(LocalDate date, List<RankingEntry> entries) {
if (entries.isEmpty()) {
return List.of();
}
List<RankingInfo> items = new ArrayList<>(entries.size());
for (RankingEntry entry : entries) {
CachedProductSnapshot snapshot = productCache.findById(entry.productDbId());
if (snapshot == null || snapshot.deleted()) {
continue;
}
Long globalRank = rankingRepository.findRank(date, entry.productDbId()).orElse(null);
long rank = globalRank != null ? globalRank : 0L;
items.add(toInfo(entry, rank, snapshot));
}
return items;
}

public RankingPageResult getHourlyTopN(LocalDate date, int hour, long page, long size) {
long offset = page * size;
List<RankingEntry> entries = rankingRepository.findHourlyTopN(date, hour, offset, size);
long totalElements = rankingRepository.countHourlyMembers(date, hour);
List<RankingInfo> items = enrich(entries, offset);
return new RankingPageResult(items, page, size, totalElements);
List<RankingInfo> items = enrichByOffset(entries, offset);
return new RankingPageResult(items, page, size, totalElements, null);
}

public RankingCursorResult getHourlyByCursor(LocalDate date, int hour, Double cursorScore, long size) {
List<RankingEntry> entries = rankingRepository.findHourlyCursor(date, hour, cursorScore, size);
List<RankingInfo> items = enrichHourlyCursor(date, hour, entries);
List<RankingInfo> items = enrichWith(entries,
(i, e) -> rankingRepository.findHourlyRank(date, hour, e.productDbId()).orElse(0L));
Double nextCursor = items.isEmpty() ? null : entries.get(entries.size() - 1).score();
return new RankingCursorResult(items, nextCursor);
}

private List<RankingInfo> enrichHourlyCursor(LocalDate date, int hour, List<RankingEntry> entries) {
private RankingPageResult queryMvPeriod(RankPeriodType type, String periodKey, long page, long size, long offset, boolean isFallback) {
List<RankingEntry> entries = mvProductRankRepository.findByPeriodKey(type, periodKey, offset, size);
long totalElements = mvProductRankRepository.countByPeriodKey(type, periodKey);
java.time.ZonedDateTime lastUpdatedAt = mvProductRankRepository.findLastUpdatedAt(type, periodKey).orElse(null);
Long publishedVersion = mvProductRankRepository.findPublishedVersion(type, periodKey).orElse(null);
List<RankingInfo> items = enrichByOffset(entries, offset);
return new RankingPageResult(items, page, size, totalElements, lastUpdatedAt, periodKey, isFallback, publishedVersion);
}

private RankingPageResult getDailyTopN(LocalDate date, long page, long size, long offset) {
List<RankingEntry> entries = rankingRepository.findTopN(date, offset, size);
long totalElements = rankingRepository.countMembers(date);
List<RankingInfo> items = enrichByOffset(entries, offset);
return new RankingPageResult(items, page, size, totalElements, null);
}

private List<RankingInfo> enrichByOffset(List<RankingEntry> entries, long baseOffset) {
return enrichWith(entries, (i, e) -> baseOffset + i + 1);
}

private List<RankingInfo> enrichWith(List<RankingEntry> entries, BiFunction<Integer, RankingEntry, Long> rankResolver) {
if (entries.isEmpty()) {
return List.of();
}
List<Long> ids = entries.stream().map(RankingEntry::productDbId).toList();
Map<Long, CachedProductSnapshot> snapshots = productCache.findAllByIds(ids);
List<RankingInfo> items = new ArrayList<>(entries.size());
for (RankingEntry entry : entries) {
CachedProductSnapshot snapshot = productCache.findById(entry.productDbId());
if (snapshot == null || snapshot.deleted()) {
continue;
}
Long globalRank = rankingRepository.findHourlyRank(date, hour, entry.productDbId()).orElse(null);
long rank = globalRank != null ? globalRank : 0L;
items.add(toInfo(entry, rank, snapshot));
for (int i = 0; i < entries.size(); i++) {
RankingEntry entry = entries.get(i);
items.add(toInfo(entry, rankResolver.apply(i, entry), snapshots.get(entry.productDbId())));
}
return items;
}

private RankingInfo toInfo(RankingEntry entry, long rank, CachedProductSnapshot snapshot) {
return new RankingInfo(
rank,
entry.score(),
snapshot.id(),
snapshot.productId(),
snapshot.productName(),
snapshot.price(),
RankingInfo.STATUS_ACTIVE
);
if (snapshot == null || snapshot.deleted()) {
return new RankingInfo(rank, entry.score(), entry.productDbId(),
null, null, null, RankingInfo.STATUS_DISCONTINUED);
}
return new RankingInfo(rank, entry.score(), snapshot.id(),
snapshot.productId(), snapshot.productName(), snapshot.price(),
RankingInfo.STATUS_ACTIVE);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
package com.loopers.application.ranking;

import java.time.ZonedDateTime;
import java.util.List;

public record RankingPageResult(
List<RankingInfo> items,
long page,
long size,
long totalElements
long totalElements,
ZonedDateTime lastUpdatedAt,
String periodKey,
boolean isFallback,
Long publishedVersion
) {
public RankingPageResult(List<RankingInfo> items, long page, long size, long totalElements, ZonedDateTime lastUpdatedAt) {
this(items, page, size, totalElements, lastUpdatedAt, null, false, null);
}

public RankingPageResult(List<RankingInfo> items, long page, long size, long totalElements,
ZonedDateTime lastUpdatedAt, String periodKey, boolean isFallback) {
this(items, page, size, totalElements, lastUpdatedAt, periodKey, isFallback, null);
}
}
Loading