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
4 changes: 2 additions & 2 deletions .github/workflows/codex-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
if: ${{ github.event.pull_request.draft == false }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_REVIEW_MODEL: ${{ vars.CODEX_REVIEW_MODEL || 'gpt-5.4-mini' }}
CODEX_REVIEW_MODEL: ${{ vars.CODEX_REVIEW_MODEL || 'gpt-5.6-luna' }}

steps:
- name: Checkout base branch prompts
Expand All @@ -37,7 +37,7 @@ jobs:

const marker = '<!-- codex-pr-review -->';
const maxDiffChars = 90000;
const model = process.env.CODEX_REVIEW_MODEL || 'gpt-5.4-mini';
const model = process.env.CODEX_REVIEW_MODEL || 'gpt-5.6-luna';
const apiKey = process.env.OPENAI_API_KEY;

if (!apiKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public interface AdminSyncJobRepository extends JpaRepository<AdminSyncJob, Long

List<AdminSyncJob> findAllByStatusIn(Collection<AdminSyncJobStatus> statuses);

boolean existsByTaskAndDetailsAndStatusIn(
String task, String details, Collection<AdminSyncJobStatus> statuses);

@EntityGraph(attributePaths = "adminUser")
List<AdminSyncJob> findAllByStatusInOrderByCreatedAtDesc(Collection<AdminSyncJobStatus> statuses);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import com.son.soccerStreaming.apifootball.service.ApiFootballPlayerSyncService;
import com.son.soccerStreaming.apifootball.service.ApiFootballStandingSyncService;
import com.son.soccerStreaming.apifootball.service.ApiFootballTeamSyncService;
import com.son.soccerStreaming.apifootball.service.ApiFootballSyncAlreadyRunningException;
import com.son.soccerStreaming.apifootball.service.ApiFootballSyncExecutionGuard;
import com.son.soccerStreaming.apifootball.scheduler.ApiFootballSyncFailureRetryScheduler;
import com.son.soccerStreaming.apifootball.service.LeagueSeasonCoverageSyncService;
import com.son.soccerStreaming.admin.dto.AdminDto;
import com.son.soccerStreaming.admin.entity.AdminAuditLog;
Expand Down Expand Up @@ -162,6 +165,8 @@ public class AdminService {
private final LeagueSeasonCoverageSyncService leagueSeasonCoverageSyncService;
private final AdminSyncTaskRunner adminSyncTaskRunner;
private final AdminSyncJobService adminSyncJobService;
private final ApiFootballSyncExecutionGuard apiFootballSyncExecutionGuard;
private final ApiFootballSyncFailureRetryScheduler apiFootballSyncFailureRetryScheduler;
private final MediaUrlService mediaUrlService;
private final ConcurrentMap<String, ManualSyncState> manualSyncStates = new ConcurrentHashMap<>();

Expand Down Expand Up @@ -643,9 +648,10 @@ public AdminDto.SyncStatusResponse getSyncStatuses(Integer season) {
private AdminDto.SyncResponse runSync(Long adminUserId, String task, String targetType, Long targetId, String details, SyncTask syncTask) {
AppUser admin = findUser(adminUserId);
String syncKey = manualSyncKey(task, details);
acquireManualSync(syncKey);
ApiFootballSyncExecutionGuard.Lease reservation = acquireManualSync(syncKey);
try {
int count = syncTask.run();
apiFootballSyncFailureRetryScheduler.cancelPendingByExecutionKey(syncKey);
String message = task + " sync completed. " + details + "; count=" + count;
adminAuditLogRepository.save(AdminAuditLog.of(admin, AdminAuditType.SYNC, targetType, targetId, message, details, true));
return AdminDto.SyncResponse.builder()
Expand All @@ -665,7 +671,7 @@ private AdminDto.SyncResponse runSync(Long adminUserId, String task, String targ
.message(message)
.build();
} finally {
releaseManualSync(syncKey);
releaseManualSync(syncKey, reservation);
}
}

Expand All @@ -677,7 +683,7 @@ public AdminDto.SyncCancelResponse cancelSyncJob(Long adminUserId, Long jobId) {
AppUser admin = findUser(adminUserId);
AdminSyncJobService.CancelResult result = adminSyncJobService.requestCancel(jobId);
if (result.cancelledBeforeStart()) {
releaseManualSync(manualSyncKey(result.task(), result.details()));
releaseCurrentManualSync(manualSyncKey(result.task(), result.details()));
}
String message = result.cancelledBeforeStart()
? result.task() + " sync cancelled before start."
Expand All @@ -698,22 +704,36 @@ private AdminDto.SyncResponse queueSync(Long adminUserId, String task, String ta
// Keep the admin request short; the runner writes start/completion audit logs from a worker thread.
findUser(adminUserId);
String syncKey = manualSyncKey(task, details);
acquireManualSync(syncKey);
var job = adminSyncJobService.create(adminUserId, task, targetType, targetId, season, details);
if (adminSyncJobService.hasActiveJob(task, details)) {
throw new CustomException(ErrorCode.ADMIN_SYNC_ALREADY_RUNNING);
}
ApiFootballSyncExecutionGuard.Lease reservation = acquireManualSync(syncKey);
final com.son.soccerStreaming.admin.entity.AdminSyncJob job;
try {
if (adminSyncJobService.hasActiveJob(task, details)) {
throw new CustomException(ErrorCode.ADMIN_SYNC_ALREADY_RUNNING);
}
job = adminSyncJobService.create(adminUserId, task, targetType, targetId, season, details);
} catch (RuntimeException exception) {
releaseManualSync(syncKey, reservation);
throw exception;
}
AdminSyncTaskRunner.SyncTask trackedSyncTask = progress -> {
try {
return syncTask.run(progress);
int count = syncTask.run(progress);
apiFootballSyncFailureRetryScheduler.cancelPendingByExecutionKey(syncKey);
return count;
} catch (Exception exception) {
recordSyncFailure(task, season, exception);
throw exception;
}
};
try {
adminSyncTaskRunner.run(job.getId(), adminUserId, task, targetType, targetId, details,
trackedSyncTask, () -> releaseManualSync(syncKey));
trackedSyncTask, () -> releaseManualSync(syncKey, reservation));
} catch (RuntimeException exception) {
adminSyncJobService.markFailed(job.getId(), task + " sync could not be queued: " + exception.getMessage());
releaseManualSync(syncKey);
releaseManualSync(syncKey, reservation);
throw exception;
}
return AdminDto.SyncResponse.builder()
Expand All @@ -730,21 +750,44 @@ private String manualSyncKey(String task, String details) {
return task + ":" + details;
}

private void acquireManualSync(String syncKey) {
private ApiFootballSyncExecutionGuard.Lease acquireManualSync(String syncKey) {
final ApiFootballSyncExecutionGuard.Lease reservation;
try {
reservation = apiFootballSyncExecutionGuard.acquire(syncKey);
} catch (ApiFootballSyncAlreadyRunningException exception) {
throw new CustomException(ErrorCode.ADMIN_SYNC_ALREADY_RUNNING);
}
Instant now = Instant.now();
manualSyncStates.compute(syncKey, (key, current) -> {
if (current != null && current.running()) {
throw new CustomException(ErrorCode.ADMIN_SYNC_TOO_FREQUENT);
}
if (current != null && Duration.between(current.requestedAt(), now).compareTo(MANUAL_SYNC_COOLDOWN) < 0) {
throw new CustomException(ErrorCode.ADMIN_SYNC_TOO_FREQUENT);
}
return new ManualSyncState(now, true);
});
try {
manualSyncStates.compute(syncKey, (key, current) -> {
if (current != null && current.running()) {
throw new CustomException(ErrorCode.ADMIN_SYNC_ALREADY_RUNNING);
}
if (current != null && Duration.between(current.requestedAt(), now).compareTo(MANUAL_SYNC_COOLDOWN) < 0) {
throw new CustomException(ErrorCode.ADMIN_SYNC_TOO_FREQUENT);
}
return new ManualSyncState(now, true, reservation);
});
return reservation;
} catch (RuntimeException exception) {
apiFootballSyncExecutionGuard.release(reservation);
throw exception;
}
}

private void releaseManualSync(String syncKey, ApiFootballSyncExecutionGuard.Lease reservation) {
manualSyncStates.computeIfPresent(syncKey, (key, current) ->
Objects.equals(current.reservation(), reservation)
? new ManualSyncState(current.requestedAt(), false, current.reservation())
: current);
apiFootballSyncExecutionGuard.release(reservation);
}

private void releaseManualSync(String syncKey) {
manualSyncStates.computeIfPresent(syncKey, (key, current) -> new ManualSyncState(current.requestedAt(), false));
private void releaseCurrentManualSync(String syncKey) {
ManualSyncState current = manualSyncStates.get(syncKey);
if (current != null) {
releaseManualSync(syncKey, current.reservation());
}
}

private String syncDetails(Integer league, Integer season) {
Expand Down Expand Up @@ -889,7 +932,8 @@ private String valueText(Object value) {
private record FieldChange(String fieldName, Object previousValue, Object newValue) {
}

private record ManualSyncState(Instant requestedAt, boolean running) {
private record ManualSyncState(Instant requestedAt, boolean running,
ApiFootballSyncExecutionGuard.Lease reservation) {
}

private Fixture findFixtureWithTeams(Long fixtureId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
@RequiredArgsConstructor
public class AdminSyncJobService {

private static final List<AdminSyncJobStatus> ACTIVE_STATUSES = List.of(
AdminSyncJobStatus.QUEUED,
AdminSyncJobStatus.RUNNING,
AdminSyncJobStatus.CANCEL_REQUESTED
);

private final AdminSyncJobRepository jobRepository;
private final AdminSyncJobErrorRepository errorRepository;
private final AppUserRepository appUserRepository;
Expand All @@ -40,6 +46,11 @@ public AdminSyncJob create(Long adminUserId, String task, String targetType, Lon
return jobRepository.save(AdminSyncJob.queued(admin, task, targetType, targetId, season, details));
}

@Transactional(readOnly = true)
public boolean hasActiveJob(String task, String details) {
return jobRepository.existsByTaskAndDetailsAndStatusIn(task, details, ACTIVE_STATUSES);
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean markRunning(Long jobId) {
return job(jobId).markRunning();
Expand Down Expand Up @@ -93,16 +104,14 @@ public CancelResult requestCancel(Long jobId) {
@Transactional(readOnly = true)
public AdminDto.SyncJobListResponse recentJobs(Integer limit) {
int safeLimit = limit == null ? 10 : Math.min(Math.max(limit, 1), 50);
List<AdminSyncJobStatus> activeStatuses = List.of(
AdminSyncJobStatus.QUEUED, AdminSyncJobStatus.RUNNING, AdminSyncJobStatus.CANCEL_REQUESTED);
List<AdminSyncJob> activeJobs = new ArrayList<>(
jobRepository.findAllByStatusInOrderByCreatedAtDesc(activeStatuses));
jobRepository.findAllByStatusInOrderByCreatedAtDesc(ACTIVE_STATUSES));
activeJobs.sort(Comparator
.comparingInt((AdminSyncJob job) -> activeRank(job.getStatus()))
.thenComparing(AdminSyncJob::getCreatedAt));
List<AdminSyncJob> jobs = new ArrayList<>(activeJobs);
jobs.addAll(jobRepository.findAllByStatusNotInOrderByCompletedAtDesc(
activeStatuses, PageRequest.of(0, safeLimit)));
ACTIVE_STATUSES, PageRequest.of(0, safeLimit)));
List<Long> ids = jobs.stream().map(AdminSyncJob::getId).toList();
Map<Long, List<AdminSyncJobError>> errorsByJob = ids.isEmpty()
? Map.of()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.son.soccerStreaming.apifootball.client;

import com.son.soccerStreaming.global.externalapi.ExternalApiErrorCategory;
import com.son.soccerStreaming.global.externalapi.ExternalApiException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
Expand Down Expand Up @@ -49,9 +48,7 @@ public void recordSuccess() {
}

public void recordFailure(String operation, Exception exception) {
if (!enabled || exception instanceof ApiFootballCircuitOpenException
|| exception instanceof ExternalApiException external
&& external.getCategory() == ExternalApiErrorCategory.CIRCUIT_OPEN) {
if (!enabled || !countsAsProviderFailure(exception)) {
return;
}
int failures = consecutiveFailures.incrementAndGet();
Expand All @@ -65,6 +62,19 @@ public void recordFailure(String operation, Exception exception) {
operation, failures, openUntil);
}

boolean countsAsProviderFailure(Exception exception) {
if (exception instanceof ApiFootballCircuitOpenException) {
return false;
}
if (!(exception instanceof ExternalApiException external)) {
return true;
}
return switch (external.getCategory()) {
case TIMEOUT, NETWORK, UPSTREAM_SERVER, INVALID_RESPONSE -> true;
default -> false;
};
}

private int configuredFailureThreshold() {
return Math.max(1, failureThreshold);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.son.soccerStreaming.apifootball.dto.ApiFootballLineupDto;
import com.son.soccerStreaming.apifootball.dto.ApiFootballLiveDto;
import com.son.soccerStreaming.apifootball.dto.ApiFootballPlayerDto;
import com.son.soccerStreaming.apifootball.dto.ApiFootballResponseEnvelope;
import com.son.soccerStreaming.apifootball.dto.ApiFootballStandingDto;
import com.son.soccerStreaming.apifootball.dto.ApiFootballTeamDto;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -267,18 +268,21 @@ public List<ApiFootballStandingDto.StandingResponse> getStandings(Integer league
return standingResponseOf(body);
}

private <T> T get(String operation, String path, ParameterizedTypeReference<T> responseType, Object... uriVariables) {
private <T extends ApiFootballResponseEnvelope<?>> T get(
String operation, String path, ParameterizedTypeReference<T> responseType, Object... uriVariables) {
try {
return externalApiExecutor.execute(ExternalApiProvider.API_FOOTBALL, operation, () -> {
T response = externalApiExecutor.execute(ExternalApiProvider.API_FOOTBALL, operation, () -> {
apiFootballCircuitBreaker.beforeRequest(operation);
T response = apiFootballRestClient.get()
var responseEntity = apiFootballRestClient.get()
.uri(baseUrl + path, uriVariables)
.headers(this::setApiHeaders)
.retrieve()
.body(responseType);
apiFootballCircuitBreaker.recordSuccess();
return response;
.toEntity(responseType);
return ApiFootballResponseValidator.validate(
operation, responseEntity.getStatusCode(), responseEntity.getBody());
});
apiFootballCircuitBreaker.recordSuccess();
return response;
} catch (ExternalApiException exception) {
apiFootballCircuitBreaker.recordFailure(operation, exception);
throw exception;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.son.soccerStreaming.apifootball.client;

import com.fasterxml.jackson.databind.JsonNode;
import com.son.soccerStreaming.apifootball.dto.ApiFootballResponseEnvelope;
import com.son.soccerStreaming.global.externalapi.ExternalApiErrorCategory;
import com.son.soccerStreaming.global.externalapi.ExternalApiException;
import com.son.soccerStreaming.global.externalapi.ExternalApiProvider;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;

import java.util.List;

final class ApiFootballResponseValidator {

private ApiFootballResponseValidator() {
}

static <T extends ApiFootballResponseEnvelope<?>> T validate(
String operation, HttpStatusCode status, T body) {
if (status.value() == HttpStatus.NO_CONTENT.value()) {
return body;
}
if (body == null) {
throw invalidResponse(operation, status, "API-Football returned an empty response body");
}

JsonNode errors = body.getErrors();
if (errors == null) {
throw invalidResponse(operation, status, "API-Football response did not contain errors metadata");
}
if (!errors.isContainerNode() || !errors.isEmpty()) {
throw invalidResponse(operation, status, "API-Football returned an error response");
}

Integer results = body.getResults();
List<?> response = body.getResponse();
if (results == null || results < 0 || response == null) {
throw invalidResponse(operation, status, "API-Football response envelope was incomplete");
}
if (results != response.size()) {
throw invalidResponse(operation, status, "API-Football response result count was inconsistent");
}
return body;
}

private static ExternalApiException invalidResponse(
String operation, HttpStatusCode status, String message) {
return new ExternalApiException(
ExternalApiProvider.API_FOOTBALL,
operation,
ExternalApiErrorCategory.INVALID_RESPONSE,
status.value(),
false,
null,
message,
null
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ private ApiFootballFixtureStatisticsDto() {
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ApiResponse<T> {
private List<T> response;
public static class ApiResponse<T> extends ApiFootballResponseEnvelope<T> {
}

@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ private ApiFootballInjuryDto() {
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ApiResponse<T> {
private List<T> response;
public static class ApiResponse<T> extends ApiFootballResponseEnvelope<T> {
}

@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ private ApiFootballLeagueDto() {
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ApiResponse<T> {
private List<T> response;
public static class ApiResponse<T> extends ApiFootballResponseEnvelope<T> {
}

@Getter
Expand Down
Loading
Loading