Skip to content
Merged
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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.4.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,14 @@
import java.util.Map;
import java.util.Objects;
import java.util.OptionalDouble;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Limit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Limit;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
Expand All @@ -66,6 +69,7 @@ public class BrandCollaborationService {
private static final int SHORTS_MAX_DURATION_SECONDS = 180;
private static final int RECENT_UPLOADS_COUNT = 10;
private static final int DEFAULT_VIDEO_COUNT = 9;
private static final Duration RECENT_UPLOAD_DAYS_TIMEOUT = Duration.ofSeconds(6);

private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private static final String DEFAULT_VIDEOS_CACHE_KEY = "brand-collaboration:default-videos";
Expand All @@ -78,6 +82,7 @@ public class BrandCollaborationService {
private final RedisTemplate<String, String> redisTemplate;
private final VideoRepository videoRepository;
private final VideoStatsRepository videoStatsRepository;
private final ExecutorService externalApiExecutor;

public CursorSliceResponse<BrandCollaborationVideoResponse> search(BrandCollaborationSearchCondition condition) {
if (condition.includeKeywords().isEmpty()) {
Expand Down Expand Up @@ -269,24 +274,45 @@ private List<BrandCollaborationTrendsResponse.CategoryShare> computeCategoryDist
}

private Double computeAvgUploadDays(Map<String, YoutubeDataChannelResponse.Item> channelMap) {
OptionalDouble avg = channelMap.values().stream()
List<CompletableFuture<Double>> futures = channelMap.values().stream()
.filter(c -> c.contentDetails() != null && c.contentDetails().relatedPlaylists() != null)
.map(c -> c.contentDetails().relatedPlaylists().uploads())
.filter(StringUtils::hasText)
.mapToDouble(playlistId -> {
List<Instant> instants = youtubeDataApiClient
.getRecentUploadDates(playlistId, RECENT_UPLOADS_COUNT)
.stream().map(Instant::parse).sorted().toList();
if (instants.size() < 2) return Double.NaN;
long spanSeconds = instants.getLast().getEpochSecond() - instants.getFirst().getEpochSecond();
return spanSeconds / 86400.0 / (instants.size() - 1);
})
.map(playlistId -> CompletableFuture
.supplyAsync(() -> computeUploadDays(playlistId), externalApiExecutor)
.orTimeout(
RECENT_UPLOAD_DAYS_TIMEOUT.toSeconds(),
TimeUnit.SECONDS
)
.exceptionally(exception -> {
log.warn("Failed to compute average upload days. playlistId={}", playlistId, exception);
return Double.NaN;
}))
.toList();

OptionalDouble avg = futures.stream()
.map(CompletableFuture::join)
.mapToDouble(Double::doubleValue)
.filter(d -> !Double.isNaN(d) && d > 0)
.average();

return avg.isPresent() ? avg.getAsDouble() : null;
}
Comment on lines +277 to 300

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## file list\n'
git ls-files | rg '^src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService\.java$|^src/main/java/.*RestClient.*|^src/main/java/.*YouTube.*|^src/main/java/.*Executor.*|^src/main/java/.*Http.*|^src/main/resources/.*application.*'

printf '\n## outline BrandCollaborationService\n'
ast-grep outline src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java --view expanded || true

printf '\n## relevant snippets\n'
sed -n '1,220p' src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java
printf '\n--- middle ---\n'
sed -n '220,360p' src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java

printf '\n## timeout/search terms\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' \
  'orTimeout|completeOnTimeout|readTimeout|connectTimeout|timeout|RestClient|WebClient|HttpClient|ExecutorService|externalApiExecutor|computeUploadDays|analyzeTrends' \
  src/main/java src/main/resources

Repository: JECT-Study/inflace-4th-server

Length of output: 25367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## BrandCollaborationService outline\n'
ast-grep outline src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java --view expanded || true

printf '\n## BrandCollaborationService relevant lines\n'
sed -n '240,320p' src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java

printf '\n## search for timeout and async config\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' \
  'orTimeout|completeOnTimeout|readTimeout|connectTimeout|timeout|externalApiExecutor|computeUploadDays|analyzeTrends|RestClient|WebClient|HttpClient|ExecutorService' \
  src/main/java src/main/resources

Repository: JECT-Study/inflace-4th-server

Length of output: 11566


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## RestClientConfig\n'
sed -n '1,120p' src/main/java/com/example/inflace/global/config/RestClientConfig.java

printf '\n## YoutubeDataApiClient\n'
sed -n '1,260p' src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java

printf '\n## YoutubeSearchApiClient\n'
sed -n '1,220p' src/main/java/com/example/inflace/global/client/YoutubeSearchApiClient.java

printf '\n## GoogleApiClient\n'
sed -n '1,220p' src/main/java/com/example/inflace/global/client/GoogleApiClient.java

printf '\n## any timeout-related config files\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' \
  'setReadTimeout|setConnectTimeout|readTimeout|connectTimeout|timeout-duration|timeout\(' \
  src/main/java src/main/resources

Repository: JECT-Study/inflace-4th-server

Length of output: 18032


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## RestClientConfig\n'
sed -n '1,120p' src/main/java/com/example/inflace/global/config/RestClientConfig.java

printf '\n## YoutubeDataApiClient\n'
sed -n '1,260p' src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java

printf '\n## YoutubeSearchApiClient\n'
sed -n '1,220p' src/main/java/com/example/inflace/global/client/YoutubeSearchApiClient.java

printf '\n## GoogleApiClient\n'
sed -n '1,220p' src/main/java/com/example/inflace/global/client/GoogleApiClient.java

printf '\n## timeout-related settings\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' \
  'setReadTimeout|setConnectTimeout|readTimeout|connectTimeout|timeout-duration|timeout\(' \
  src/main/java src/main/resources

Repository: JECT-Study/inflace-4th-server

Length of output: 18024


computeAvgUploadDays에 응답 제한을 두세요.
CompletableFuture::join()RestClient.builder().build()로 만든 YouTube 호출을 그대로 기다립니다. application.ymltimeout-duration은 rate limiter 대기 제한이라 HTTP 응답 지연을 막지 못하므로, orTimeout()RestClient read timeout을 함께 넣어야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java`
around lines 275 - 294, `computeAvgUploadDays` is waiting indefinitely on
YouTube requests, so add an actual response timeout instead of relying on the
rate-limiter setting alone. Update the async flow around
`CompletableFuture.supplyAsync(...)` and `CompletableFuture::join` to use
`orTimeout()` (or equivalent) for the upload-day computation, and configure the
`RestClient` used by `computeUploadDays` with a read timeout so slow HTTP
responses fail fast. Keep the existing exception handling/logging in
`BrandCollaborationService` to surface timeout failures clearly.


private Double computeUploadDays(String playlistId) {
List<Instant> instants = youtubeDataApiClient
.getRecentUploadDates(playlistId, RECENT_UPLOADS_COUNT)
.stream()
.map(Instant::parse)
.sorted()
.toList();
if (instants.size() < 2) {
return Double.NaN;
}
long spanSeconds = instants.getLast().getEpochSecond() - instants.getFirst().getEpochSecond();
return spanSeconds / 86400.0 / (instants.size() - 1);
}

private void validateKeywords(List<String> includeKeywords, List<String> excludeKeywords) {
boolean hasDuplicate = includeKeywords.stream().anyMatch(excludeKeywords::contains);
if (hasDuplicate) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
Expand All @@ -33,6 +36,7 @@ public class YoutubeChannelAnalyticsFetchService {

private static final int ANALYTICS_END_DATE_OFFSET_DAYS = 3;
private static final int SUBSCRIBER_LOG_MAX_BACKFILL_DAYS = 365;
private static final int VIDEO_ANALYTICS_BATCH_SIZE = 4;

private static final List<String> CHANNEL_METRICS = List.of(
"views",
Expand All @@ -57,12 +61,18 @@ public class YoutubeChannelAnalyticsFetchService {
);

private final YoutubeAnalyticsApiClient youtubeAnalyticsApiClient;
private final ExecutorService externalApiExecutor;

public YoutubeAnalyticsSyncData fetchAnalytics(String googleId, AnalyticsSyncContext context) {
LocalDate endDate = resolveEndDate();
CompletableFuture<YoutubeAnalyticsSyncData.ChannelAnalyticsData> channelAnalyticsFuture = CompletableFuture
.supplyAsync(() -> fetchChannelAnalytics(googleId, context, endDate), externalApiExecutor);
CompletableFuture<List<YoutubeAnalyticsSyncData.SubscriberLogData>> subscriberLogsFuture = CompletableFuture
.supplyAsync(() -> fetchSubscriberLogs(googleId, context, endDate), externalApiExecutor);

return new YoutubeAnalyticsSyncData(
fetchChannelAnalytics(googleId, context, endDate),
fetchSubscriberLogs(googleId, context, endDate),
channelAnalyticsFuture.join(),
subscriberLogsFuture.join(),
fetchVideoAnalytics(googleId, context.videos(), endDate),
fetchAudienceRetention(googleId, context.videos(), endDate)
);
Expand Down Expand Up @@ -185,43 +195,63 @@ private List<YoutubeAnalyticsSyncData.VideoAnalyticsData> fetchVideoAnalytics(
LocalDate endDate
) {
List<YoutubeAnalyticsSyncData.VideoAnalyticsData> result = new ArrayList<>();
for (AnalyticsSyncContext.VideoContext video : videos) {
LocalDate startDate = resolveStartDate(video.publishedAt(), endDate);
if (startDate.isAfter(endDate)) {
continue;
}
for (List<AnalyticsSyncContext.VideoContext> batch : chunked(videos, VIDEO_ANALYTICS_BATCH_SIZE)) {
List<CompletableFuture<YoutubeAnalyticsSyncData.VideoAnalyticsData>> futures = batch.stream()
.map(video -> CompletableFuture
.supplyAsync(() -> fetchVideoAnalyticsForVideo(googleId, video, endDate), externalApiExecutor)
.exceptionally(exception -> {
log.warn("Failed to schedule video analytics sync. videoId={} youtubeVideoId={}",
video.videoId(), video.youtubeVideoId(), exception);
return defaultVideoAnalyticsData(video.videoId(), !video.existingVideoAnalytics());
}))
.toList();

result.addAll(futures.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.toList());
}
return result;
}

YoutubeAnalyticsVideoRequest request = new YoutubeAnalyticsVideoRequest(
startDate,
endDate,
VIDEO_METRICS,
video.youtubeVideoId(),
"video",
null
);
Map<String, Object> summary;
try {
summary = querySingleRow(googleId, request);
} catch (ApiException e) {
log.warn("Failed to sync video analytics summary. videoId={} youtubeVideoId={} startDate={} endDate={}",
video.videoId(), video.youtubeVideoId(), startDate, endDate, e);
result.add(defaultVideoAnalyticsData(video.videoId(), !video.existingVideoAnalytics()));
continue;
}
private YoutubeAnalyticsSyncData.VideoAnalyticsData fetchVideoAnalyticsForVideo(
String googleId,
AnalyticsSyncContext.VideoContext video,
LocalDate endDate
) {
LocalDate startDate = resolveStartDate(video.publishedAt(), endDate);
if (startDate.isAfter(endDate)) {
return null;
}

if (summary.isEmpty() && !video.existingVideoAnalytics()) {
continue;
}
YoutubeAnalyticsVideoRequest request = new YoutubeAnalyticsVideoRequest(
startDate,
endDate,
VIDEO_METRICS,
video.youtubeVideoId(),
"video",
null
);
Map<String, Object> summary;
try {
summary = querySingleRow(googleId, request);
} catch (ApiException e) {
log.warn("Failed to sync video analytics summary. videoId={} youtubeVideoId={} startDate={} endDate={}",
video.videoId(), video.youtubeVideoId(), startDate, endDate, e);
return defaultVideoAnalyticsData(video.videoId(), !video.existingVideoAnalytics());
}

result.add(fetchUnsubscribedVideoAnalytics(
googleId,
video,
startDate,
endDate,
summary
));
if (summary.isEmpty() && !video.existingVideoAnalytics()) {
return null;
}
return result;

return fetchUnsubscribedVideoAnalytics(
googleId,
video,
startDate,
endDate,
summary
);
}

private YoutubeAnalyticsSyncData.VideoAnalyticsData fetchUnsubscribedVideoAnalytics(
Expand Down Expand Up @@ -292,54 +322,75 @@ private List<YoutubeAnalyticsSyncData.AudienceRetentionData> fetchAudienceRetent
LocalDate endDate
) {
List<YoutubeAnalyticsSyncData.AudienceRetentionData> result = new ArrayList<>();
for (AnalyticsSyncContext.VideoContext video : videos) {
LocalDate startDate = resolveStartDate(video.publishedAt(), endDate);
if (startDate.isAfter(endDate)) {
continue;
}
for (List<AnalyticsSyncContext.VideoContext> batch : chunked(videos, VIDEO_ANALYTICS_BATCH_SIZE)) {
List<CompletableFuture<List<YoutubeAnalyticsSyncData.AudienceRetentionData>>> futures = batch.stream()
.map(video -> CompletableFuture
.supplyAsync(() -> fetchAudienceRetentionForVideo(googleId, video, endDate), externalApiExecutor)
.exceptionally(exception -> {
log.warn("Failed to schedule audience retention sync. videoId={} youtubeVideoId={}",
video.videoId(), video.youtubeVideoId(), exception);
return List.of();
}))
.toList();

result.addAll(futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.toList());
}
return result;
}

YoutubeAnalyticsVideoRequest request = new YoutubeAnalyticsVideoRequest(
startDate,
endDate,
VIDEO_RETENTION_METRICS,
video.youtubeVideoId(),
"elapsedVideoTimeRatio",
null
);
try {
YoutubeAnalyticsVideoResponse response = youtubeAnalyticsApiClient.getYoutubeAnalytics(googleId, request);
if (response.rows() == null || response.rows().isEmpty()) {
continue;
}
private List<YoutubeAnalyticsSyncData.AudienceRetentionData> fetchAudienceRetentionForVideo(
String googleId,
AnalyticsSyncContext.VideoContext video,
LocalDate endDate
) {
LocalDate startDate = resolveStartDate(video.publishedAt(), endDate);
if (startDate.isAfter(endDate)) {
return List.of();
}

Map<String, Integer> indexMap = buildIndexMap(response.columnHeaders());
LocalDateTime now = LocalDateTime.now();

List<YoutubeAnalyticsSyncData.AudienceRetentionPointData> retentions = response.rows().stream()
.map(row -> new YoutubeAnalyticsSyncData.AudienceRetentionPointData(
toDouble(row.get(indexMap.get("elapsedVideoTimeRatio"))),
toDouble(row.get(indexMap.get("audienceWatchRatio"))),
now
))
.toList();

double relativeRetentionAvg = response.rows().stream()
.mapToDouble(row -> toDouble(row.get(indexMap.get("relativeRetentionPerformance"))))
.average()
.orElse(0.0);

result.add(new YoutubeAnalyticsSyncData.AudienceRetentionData(
video.videoId(),
retentions,
relativeRetentionAvg
));
} catch (ApiException e) {
log.warn("Failed to sync audience retention. videoId={} youtubeVideoId={} startDate={} endDate={}",
video.videoId(), video.youtubeVideoId(), startDate, endDate, e);
continue;
YoutubeAnalyticsVideoRequest request = new YoutubeAnalyticsVideoRequest(
startDate,
endDate,
VIDEO_RETENTION_METRICS,
video.youtubeVideoId(),
"elapsedVideoTimeRatio",
null
);
try {
YoutubeAnalyticsVideoResponse response = youtubeAnalyticsApiClient.getYoutubeAnalytics(googleId, request);
if (response.rows() == null || response.rows().isEmpty()) {
return List.of();
}

Map<String, Integer> indexMap = buildIndexMap(response.columnHeaders());
LocalDateTime now = LocalDateTime.now();

List<YoutubeAnalyticsSyncData.AudienceRetentionPointData> retentions = response.rows().stream()
.map(row -> new YoutubeAnalyticsSyncData.AudienceRetentionPointData(
toDouble(row.get(indexMap.get("elapsedVideoTimeRatio"))),
toDouble(row.get(indexMap.get("audienceWatchRatio"))),
now
))
.toList();

double relativeRetentionAvg = response.rows().stream()
.mapToDouble(row -> toDouble(row.get(indexMap.get("relativeRetentionPerformance"))))
.average()
.orElse(0.0);

return List.of(new YoutubeAnalyticsSyncData.AudienceRetentionData(
video.videoId(),
retentions,
relativeRetentionAvg
));
} catch (ApiException e) {
log.warn("Failed to sync audience retention. videoId={} youtubeVideoId={} startDate={} endDate={}",
video.videoId(), video.youtubeVideoId(), startDate, endDate, e);
return List.of();
}
return result;
}

private Map<String, Long> querySubscriberViews(
Expand Down Expand Up @@ -564,6 +615,14 @@ private double round(double value, int scale) {
.doubleValue();
}

private <T> List<List<T>> chunked(List<T> values, int size) {
List<List<T>> chunks = new ArrayList<>();
for (int start = 0; start < values.size(); start += size) {
chunks.add(values.subList(start, Math.min(values.size(), start + size)));
}
return chunks;
}

private Map<String, Integer> buildIndexMap(List<YoutubeAnalyticsVideoResponse.ColumnHeader> headers) {
return IntStream.range(0, headers.size())
.boxed()
Expand Down
Loading
Loading