From 1bfb18069fde1f4e7a9082cfa5bac0a6f40af2da Mon Sep 17 00:00:00 2001 From: HJ <16863475+hjcud@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:21:49 +0900 Subject: [PATCH] fix(competition): finalize live room leaderboards --- .../RoomFinalizationBatchConfiguration.java | 65 +++++++ .../batch/RoomFinalizationBatchRunner.java | 30 +++ .../batch/BackendBatchApplicationTest.java | 1 + .../migration/DatabaseAccessPolicy.java | 5 + .../migration/DatabaseAccessPolicyTest.java | 16 ++ .../RoomFinalizationCandidateSource.java | 27 +++ .../competition/RoomFinalizationFailure.java | 14 ++ .../competition/RoomFinalizationReport.java | 21 ++ .../competition/RoomFinalizationService.java | 133 +++++++++++++ .../competition/RoomFinalizationSource.java | 22 +++ .../competition/RoomFinalizationWorkPort.java | 16 ++ .../ScoringTemplateCatalogService.java | 5 + .../FinalRoomResultJooqAdapter.java | 16 +- .../RoomFinalizationWorkJooqAdapter.java | 180 ++++++++++++++++++ ...iquidationQuoteAdapterIntegrationTest.java | 17 ++ ...nTransitionPersistenceIntegrationTest.java | 20 +- ...LiquidationPersistenceIntegrationTest.java | 97 +++++++++- 17 files changed, 675 insertions(+), 10 deletions(-) create mode 100644 apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/RoomFinalizationBatchConfiguration.java create mode 100644 apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/RoomFinalizationBatchRunner.java create mode 100644 modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationCandidateSource.java create mode 100644 modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationFailure.java create mode 100644 modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationReport.java create mode 100644 modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationService.java create mode 100644 modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationSource.java create mode 100644 modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationWorkPort.java create mode 100644 modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/RoomFinalizationWorkJooqAdapter.java diff --git a/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/RoomFinalizationBatchConfiguration.java b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/RoomFinalizationBatchConfiguration.java new file mode 100644 index 00000000..cb556d9b --- /dev/null +++ b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/RoomFinalizationBatchConfiguration.java @@ -0,0 +1,65 @@ +package com.idea2strategy.backend.batch; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.idea2strategy.backend.application.competition.FinalRoomResultService; +import com.idea2strategy.backend.application.competition.RoomFinalizationService; +import com.idea2strategy.backend.application.competition.ScoringEvidenceService; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogService; +import com.idea2strategy.backend.application.competition.VirtualLiquidationService; +import com.idea2strategy.backend.persistence.competition.CanonicalVirtualLiquidationQuoteAdapter; +import com.idea2strategy.backend.persistence.competition.FinalRoomResultJooqAdapter; +import com.idea2strategy.backend.persistence.competition.RoomFinalizationWorkJooqAdapter; +import com.idea2strategy.backend.persistence.competition.ScoringEvidenceJooqAdapter; +import com.idea2strategy.backend.persistence.competition.ScoringTemplateCatalogJooqQueryAdapter; +import com.idea2strategy.backend.persistence.competition.VirtualLiquidationJooqAdapter; +import java.time.Clock; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration(proxyBeanMethods = false) +@EnableScheduling +@ConditionalOnProperty( + name = "idea2strategy.batch.room-finalization.enabled", + havingValue = "true", + matchIfMissing = true) +@Import({ + RoomFinalizationWorkJooqAdapter.class, + VirtualLiquidationJooqAdapter.class, + CanonicalVirtualLiquidationQuoteAdapter.class, + ScoringEvidenceJooqAdapter.class, + FinalRoomResultJooqAdapter.class, + ScoringTemplateCatalogJooqQueryAdapter.class +}) +class RoomFinalizationBatchConfiguration { + @Bean + RoomFinalizationService roomFinalizationService( + RoomFinalizationWorkJooqAdapter work, + VirtualLiquidationJooqAdapter liquidationStore, + CanonicalVirtualLiquidationQuoteAdapter quote, + ScoringEvidenceJooqAdapter evidence, + FinalRoomResultJooqAdapter results, + ScoringTemplateCatalogJooqQueryAdapter templates) { + Clock clock = Clock.systemUTC(); + ObjectMapper mapper = JsonMapper.builder().build(); + return new RoomFinalizationService( + work, + new VirtualLiquidationService(liquidationStore, quote, liquidationStore), + new ScoringEvidenceService(evidence), + new FinalRoomResultService(results, clock), + new ScoringTemplateCatalogService(templates, clock, mapper), + clock, + mapper); + } + + @Bean + RoomFinalizationBatchRunner roomFinalizationBatchRunner( + RoomFinalizationService service, + @Value("${idea2strategy.batch.room-finalization.batch-size:100}") int batchSize) { + return new RoomFinalizationBatchRunner(service, batchSize); + } +} diff --git a/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/RoomFinalizationBatchRunner.java b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/RoomFinalizationBatchRunner.java new file mode 100644 index 00000000..31513193 --- /dev/null +++ b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/RoomFinalizationBatchRunner.java @@ -0,0 +1,30 @@ +package com.idea2strategy.backend.batch; + +import com.idea2strategy.backend.application.competition.RoomFinalizationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; + +class RoomFinalizationBatchRunner { + private static final Logger log = LoggerFactory.getLogger(RoomFinalizationBatchRunner.class); + private final RoomFinalizationService service; + private final int batchSize; + + RoomFinalizationBatchRunner(RoomFinalizationService service, int batchSize) { + this.service = service; + this.batchSize = batchSize; + } + + @Scheduled(fixedDelayString = "${idea2strategy.batch.room-finalization.fixed-delay:PT10S}") + void run() { + var report = service.run(batchSize); + log.info( + "Room finalization batch completed: roomsAttempted={}, roomsFinalized={}, " + + "participationsFinalized={}, failures={}, observedAt={}", + report.roomsAttempted(), report.roomsFinalized(), report.participationsFinalized(), + report.failures().size(), report.observedAt()); + report.failures().forEach(failure -> log.warn( + "Room finalization remains retryable: roomId={}, reason={}", + failure.roomId(), failure.reason())); + } +} diff --git a/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java index 25e23718..452779d3 100644 --- a/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java +++ b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java @@ -13,6 +13,7 @@ "idea2strategy.batch.pending-registration-cleanup.enabled=false", "idea2strategy.batch.room-schedule-transition.enabled=false", "idea2strategy.batch.room-evaluation-start.enabled=false", + "idea2strategy.batch.room-finalization.enabled=false", "idea2strategy.batch.private-continuation-transition.enabled=false", "idea2strategy.batch.post-evaluation-stop-transition.enabled=false" }) diff --git a/db-migration/src/main/java/com/idea2strategy/backend/migration/DatabaseAccessPolicy.java b/db-migration/src/main/java/com/idea2strategy/backend/migration/DatabaseAccessPolicy.java index 1dd353d0..d3aa6bbc 100644 --- a/db-migration/src/main/java/com/idea2strategy/backend/migration/DatabaseAccessPolicy.java +++ b/db-migration/src/main/java/com/idea2strategy/backend/migration/DatabaseAccessPolicy.java @@ -68,6 +68,8 @@ public final class DatabaseAccessPolicy { private static final Set BATCH_UPDATED_TABLES = Set.of( new QualifiedTable("competition", "rooms"), new QualifiedTable("competition", "participations"), + new QualifiedTable("competition", "live_evaluation_segments"), + new QualifiedTable("competition", "leaderboard_snapshots"), new QualifiedTable("bot", "bots"), new QualifiedTable("bot", "continuation_deadlines"), new QualifiedTable("identity", "accounts"), @@ -93,6 +95,9 @@ public final class DatabaseAccessPolicy { new QualifiedTable("competition", "participation_events"), new QualifiedTable("competition", "backtest_period_runs"), new QualifiedTable("competition", "live_evaluation_segments"), + new QualifiedTable("competition", "leaderboard_snapshots"), + new QualifiedTable("competition", "leaderboard_entries"), + new QualifiedTable("competition", "room_final_access_grants"), new QualifiedTable("bot", "continuation_deadlines"), new QualifiedTable("backtest", "runs"), new QualifiedTable("identity", "account_closure_runs"), diff --git a/db-migration/src/test/java/com/idea2strategy/backend/migration/DatabaseAccessPolicyTest.java b/db-migration/src/test/java/com/idea2strategy/backend/migration/DatabaseAccessPolicyTest.java index f8e47907..ffbb8374 100644 --- a/db-migration/src/test/java/com/idea2strategy/backend/migration/DatabaseAccessPolicyTest.java +++ b/db-migration/src/test/java/com/idea2strategy/backend/migration/DatabaseAccessPolicyTest.java @@ -276,6 +276,8 @@ void grantsTheBatchRoleTheWritesItsScheduledJobsPerform() { for (var target : List.of( new DatabaseAccessPolicy.QualifiedTable("competition", "rooms"), new DatabaseAccessPolicy.QualifiedTable("competition", "participations"), + new DatabaseAccessPolicy.QualifiedTable("competition", "live_evaluation_segments"), + new DatabaseAccessPolicy.QualifiedTable("competition", "leaderboard_snapshots"), new DatabaseAccessPolicy.QualifiedTable("bot", "bots"), new DatabaseAccessPolicy.QualifiedTable("bot", "continuation_deadlines"))) { assertTrue( @@ -291,6 +293,9 @@ void grantsTheBatchRoleTheWritesItsScheduledJobsPerform() { new DatabaseAccessPolicy.QualifiedTable("competition", "participation_events"), new DatabaseAccessPolicy.QualifiedTable("competition", "backtest_period_runs"), new DatabaseAccessPolicy.QualifiedTable("competition", "live_evaluation_segments"), + new DatabaseAccessPolicy.QualifiedTable("competition", "leaderboard_snapshots"), + new DatabaseAccessPolicy.QualifiedTable("competition", "leaderboard_entries"), + new DatabaseAccessPolicy.QualifiedTable("competition", "room_final_access_grants"), new DatabaseAccessPolicy.QualifiedTable("bot", "continuation_deadlines"), new DatabaseAccessPolicy.QualifiedTable("backtest", "runs"))) { assertTrue( @@ -333,6 +338,17 @@ void grantsTheBatchRoleTheWritesItsScheduledJobsPerform() { target.table()), "batch has no write path into " + target.schema() + "." + target.table()); } + + // The batch can only EXPIRE an existing sanction. Manual APPLY commands are rejected by + // DeadlineBatchConfiguration, so granting INSERT here would widen the runtime role beyond + // the only sanction mutation the scheduled job can perform. + assertFalse( + DatabaseAccessPolicy.allows( + DatabaseAccessPolicy.ApplicationRole.BATCH, + DatabaseAccessPolicy.Access.INSERT, + "identity", + "account_sanctions"), + "sanction expiry updates an existing row and must not create a sanction"); } @Test void grantsTheBacktestRoleTheBotReadsItsExecutorPerforms() { diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationCandidateSource.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationCandidateSource.java new file mode 100644 index 00000000..51a8d3cb --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationCandidateSource.java @@ -0,0 +1,27 @@ +package com.idea2strategy.backend.application.competition; + +import java.util.Objects; +import java.util.UUID; + +public record RoomFinalizationCandidateSource( + UUID participationId, + UUID evaluationSegmentId, + UUID performanceSnapshotId, + long scheduledEvaluationSeconds, + long actualOperationSeconds, + int actualFillCount, + long baseRequiredOperationSeconds, + int baseRequiredFillCount) { + public RoomFinalizationCandidateSource { + Objects.requireNonNull(participationId, "participationId"); + Objects.requireNonNull(evaluationSegmentId, "evaluationSegmentId"); + Objects.requireNonNull(performanceSnapshotId, "performanceSnapshotId"); + if (scheduledEvaluationSeconds <= 0 + || actualOperationSeconds < 0 + || actualFillCount < 0 + || baseRequiredOperationSeconds < 0 + || baseRequiredFillCount < 0) { + throw new IllegalArgumentException("room finalization eligibility evidence is invalid"); + } + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationFailure.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationFailure.java new file mode 100644 index 00000000..559248c7 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationFailure.java @@ -0,0 +1,14 @@ +package com.idea2strategy.backend.application.competition; + +import java.util.Objects; +import java.util.UUID; + +public record RoomFinalizationFailure(UUID roomId, String reason) { + public RoomFinalizationFailure { + Objects.requireNonNull(roomId, "roomId"); + Objects.requireNonNull(reason, "reason"); + if (reason.isBlank()) { + throw new IllegalArgumentException("reason must not be blank"); + } + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationReport.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationReport.java new file mode 100644 index 00000000..49f3dabe --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationReport.java @@ -0,0 +1,21 @@ +package com.idea2strategy.backend.application.competition; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +public record RoomFinalizationReport( + Instant observedAt, + int roomsAttempted, + int roomsFinalized, + int participationsFinalized, + List failures) { + public RoomFinalizationReport { + Objects.requireNonNull(observedAt, "observedAt"); + failures = List.copyOf(Objects.requireNonNull(failures, "failures")); + if (roomsAttempted < 0 || roomsFinalized < 0 || participationsFinalized < 0 + || roomsFinalized + failures.size() > roomsAttempted) { + throw new IllegalArgumentException("room finalization report counts are invalid"); + } + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationService.java new file mode 100644 index 00000000..3bee3994 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationService.java @@ -0,0 +1,133 @@ +package com.idea2strategy.backend.application.competition; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Clock; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** Completes due live-paper evidence and publishes one immutable FINAL leaderboard per room. */ +public final class RoomFinalizationService { + private final RoomFinalizationWorkPort workPort; + private final VirtualLiquidationService liquidationService; + private final ScoringEvidenceService evidenceService; + private final FinalRoomResultService resultService; + private final ScoringTemplateCatalogService templateCatalog; + private final OfficialScoringCalculator scoring = new OfficialScoringCalculator(); + private final Clock clock; + private final ObjectMapper mapper; + + public RoomFinalizationService( + RoomFinalizationWorkPort workPort, + VirtualLiquidationService liquidationService, + ScoringEvidenceService evidenceService, + FinalRoomResultService resultService, + ScoringTemplateCatalogService templateCatalog, + Clock clock, + ObjectMapper mapper) { + this.workPort = Objects.requireNonNull(workPort, "workPort"); + this.liquidationService = Objects.requireNonNull(liquidationService, "liquidationService"); + this.evidenceService = Objects.requireNonNull(evidenceService, "evidenceService"); + this.resultService = Objects.requireNonNull(resultService, "resultService"); + this.templateCatalog = Objects.requireNonNull(templateCatalog, "templateCatalog"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + } + + public RoomFinalizationReport run(int limit) { + if (limit <= 0) { + throw new IllegalArgumentException("limit must be positive"); + } + var observedAt = clock.instant(); + List roomIds = workPort.findDueRoomIds(observedAt, limit); + List failures = new ArrayList<>(); + int roomsFinalized = 0; + int participationsFinalized = 0; + for (UUID roomId : roomIds) { + try { + for (VirtualLiquidationRequest request : workPort.findPendingLiquidations(roomId)) { + liquidationService.finalizeEvaluation(request); + workPort.markEvaluationCompleted(request, observedAt); + participationsFinalized++; + } + var ready = workPort.loadReadyResult(roomId); + if (ready.isEmpty()) { + continue; + } + resultService.finalize(command(ready.orElseThrow())); + roomsFinalized++; + } catch (RuntimeException exception) { + String message = exception.getMessage(); + failures.add(new RoomFinalizationFailure( + roomId, + exception.getClass().getSimpleName() + (message == null ? "" : ": " + message))); + } + } + return new RoomFinalizationReport( + observedAt, roomIds.size(), roomsFinalized, participationsFinalized, failures); + } + + private FinalRoomResultCommand command(RoomFinalizationSource source) { + var template = templateCatalog.parseLocked(source.scoringTemplate()); + List candidates = source.candidates().stream() + .map(candidate -> candidate(source, candidate)) + .toList(); + return new FinalRoomResultCommand( + source.roomId(), template.id(), source.cutoffAt(), template, candidates); + } + + private FinalRoomResultCandidate candidate( + RoomFinalizationSource room, RoomFinalizationCandidateSource candidate) { + var evidence = evidenceService.prepare(new ScoringEvidenceRequest( + candidate.participationId(), + candidate.evaluationSegmentId(), + candidate.performanceSnapshotId(), + room.scoringTemplate().id())); + var source = evidence.source(); + var eligibility = scoring.eligibility( + candidate.scheduledEvaluationSeconds(), + candidate.scheduledEvaluationSeconds(), + candidate.actualOperationSeconds(), + candidate.baseRequiredOperationSeconds(), + candidate.actualFillCount(), + candidate.baseRequiredFillCount(), + true); + return new FinalRoomResultCandidate( + candidate.participationId(), + candidate.performanceSnapshotId(), + new OfficialScoringMetrics( + source.totalReturnPct(), source.maxDrawdownPct(), source.sharpeRatio()), + eligibility, + evidence.provenanceHash(), + calculationDocument(evidence, candidate, eligibility)); + } + + private String calculationDocument( + ScoringEvidenceBundle evidence, + RoomFinalizationCandidateSource candidate, + OfficialScoringEligibility eligibility) { + Map document = new LinkedHashMap<>(); + document.put("schemaVersion", "live-room-finalization.v1"); + document.put("provenanceVersion", evidence.provenanceVersion()); + document.put("performanceSnapshotHash", evidence.source().performanceSnapshotHash()); + document.put("roomRulesHash", evidence.source().roomRulesHash()); + document.put("scoringTemplateRulesHash", evidence.source().lockedScoringTemplateRulesHash()); + document.put("scheduledEvaluationSeconds", candidate.scheduledEvaluationSeconds()); + document.put("normalEvaluationSeconds", candidate.scheduledEvaluationSeconds()); + document.put("actualOperationSeconds", candidate.actualOperationSeconds()); + document.put("actualFillCount", candidate.actualFillCount()); + document.put("requiredOperationSeconds", eligibility.requiredOperationSeconds()); + document.put("requiredFillCount", eligibility.requiredFillCount()); + document.put("coverage", eligibility.coverage().toPlainString()); + document.put("eligibilityReasons", eligibility.reasons().stream().map(Enum::name).toList()); + try { + return mapper.writeValueAsString(document); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("room finalization evidence is not JSON serializable", exception); + } + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationSource.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationSource.java new file mode 100644 index 00000000..0ea4067d --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationSource.java @@ -0,0 +1,22 @@ +package com.idea2strategy.backend.application.competition; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public record RoomFinalizationSource( + UUID roomId, + Instant cutoffAt, + ScoringTemplateCatalogRecord scoringTemplate, + List candidates) { + public RoomFinalizationSource { + Objects.requireNonNull(roomId, "roomId"); + Objects.requireNonNull(cutoffAt, "cutoffAt"); + Objects.requireNonNull(scoringTemplate, "scoringTemplate"); + candidates = List.copyOf(Objects.requireNonNull(candidates, "candidates")); + if (candidates.isEmpty()) { + throw new IllegalArgumentException("room finalization candidates must not be empty"); + } + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationWorkPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationWorkPort.java new file mode 100644 index 00000000..da23f03f --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/RoomFinalizationWorkPort.java @@ -0,0 +1,16 @@ +package com.idea2strategy.backend.application.competition; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface RoomFinalizationWorkPort { + List findDueRoomIds(Instant observedAt, int limit); + + List findPendingLiquidations(UUID roomId); + + void markEvaluationCompleted(VirtualLiquidationRequest request, Instant completedAt); + + Optional loadReadyResult(UUID roomId); +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/ScoringTemplateCatalogService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/ScoringTemplateCatalogService.java index e7c5de21..070e9df9 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/ScoringTemplateCatalogService.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/ScoringTemplateCatalogService.java @@ -62,6 +62,11 @@ var record = queryPort } } + /** Parses the immutable version locked by a room even if it retired after room creation. */ + public ScoringTemplateVersion parseLocked(ScoringTemplateCatalogRecord record) { + return parse(Objects.requireNonNull(record, "record")); + } + private ScoringTemplateVersion parse(ScoringTemplateCatalogRecord record) { try { JsonNode root = objectMapper.readTree(record.rulesDocument()); diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/FinalRoomResultJooqAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/FinalRoomResultJooqAdapter.java index f78e1080..0b14fce1 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/FinalRoomResultJooqAdapter.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/FinalRoomResultJooqAdapter.java @@ -114,13 +114,15 @@ private void freezeSecretAccessGrants(FinalRoomResult result, UUID creatorAccoun if (creatorAccountId != null) { grants.put(creatorAccountId, "CREATOR"); } - for (FinalLeaderboardEntry entry : result.entries()) { - Record owner = dsl.fetchOne( - "select owner_account_id from competition.participations where id = ?", - entry.participationId()); - UUID ownerAccountId = owner.get("owner_account_id", UUID.class); - grants.putIfAbsent(ownerAccountId, "ACTIVE_PARTICIPANT"); - } + dsl.fetch( + "select distinct owner_account_id from competition.participations " + + "where room_id = ? and status in " + + "('COMPLETED'::competition.participation_status, " + + "'EVALUATION_FAILED'::competition.participation_status) " + + "order by owner_account_id", + result.roomId()) + .getValues("owner_account_id", UUID.class) + .forEach(accountId -> grants.putIfAbsent(accountId, "ACTIVE_PARTICIPANT")); for (var grant : grants.entrySet()) { dsl.execute( "insert into competition.room_final_access_grants " diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/RoomFinalizationWorkJooqAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/RoomFinalizationWorkJooqAdapter.java new file mode 100644 index 00000000..7c169e4b --- /dev/null +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/RoomFinalizationWorkJooqAdapter.java @@ -0,0 +1,180 @@ +package com.idea2strategy.backend.persistence.competition; + +import com.idea2strategy.backend.application.competition.RoomFinalizationCandidateSource; +import com.idea2strategy.backend.application.competition.RoomFinalizationSource; +import com.idea2strategy.backend.application.competition.RoomFinalizationWorkPort; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogRecord; +import com.idea2strategy.backend.application.competition.VirtualLiquidationRequest; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.jooq.DSLContext; +import org.jooq.Record; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class RoomFinalizationWorkJooqAdapter implements RoomFinalizationWorkPort { + private final DSLContext dsl; + + public RoomFinalizationWorkJooqAdapter(DSLContext dsl) { + this.dsl = dsl; + } + + @Override + @Transactional(readOnly = true) + public List findDueRoomIds(Instant observedAt, int limit) { + return dsl.fetch( + "select r.id from competition.rooms r " + + "join competition.room_schedules rs on rs.room_id = r.id " + + "where r.competition_type = 'LIVE_PAPER'::competition.competition_type " + + "and r.status = 'ENDED'::competition.room_status " + + "and rs.evaluation_ends_at <= ?::timestamptz " + + "and not exists (select 1 from competition.leaderboard_snapshots ls " + + "where ls.room_id = r.id and ls.status = 'FINAL'::competition.leaderboard_status) " + + "and exists (select 1 from competition.participations p where p.room_id = r.id " + + "and p.status in ('EVALUATING'::competition.participation_status, " + + "'COMPLETED'::competition.participation_status)) " + + "and not exists (select 1 from competition.participations p where p.room_id = r.id " + + "and p.status in ('REGISTERED'::competition.participation_status, " + + "'ACTIVE'::competition.participation_status, " + + "'PENDING_LEDGER'::competition.participation_status)) " + + "order by rs.evaluation_ends_at, r.id limit ?", + utc(observedAt), limit) + .getValues("id", UUID.class); + } + + @Override + @Transactional(readOnly = true) + public List findPendingLiquidations(UUID roomId) { + return dsl.fetch( + "select p.id as participation_id, s.id as segment_id " + + "from competition.participations p " + + "join competition.live_evaluation_segments s on s.participation_id = p.id " + + "where p.room_id = ? " + + "and p.status = 'EVALUATING'::competition.participation_status " + + "and s.segment_type = 'OFFICIAL_EVALUATION' and s.finalized_at is null " + + "order by p.id", + roomId) + .map(row -> new VirtualLiquidationRequest( + row.get("participation_id", UUID.class), row.get("segment_id", UUID.class))); + } + + @Override + @Transactional + public void markEvaluationCompleted(VirtualLiquidationRequest request, Instant completedAt) { + OffsetDateTime completed = utc(completedAt); + int updated = dsl.execute( + "update competition.participations p " + + "set status = 'COMPLETED'::competition.participation_status, " + + "evaluation_finished_at = (select s.ends_at from competition.live_evaluation_segments s " + + "where s.id = ? and s.participation_id = p.id), evaluation_failure_code = null " + + "where p.id = ? and p.status = 'EVALUATING'::competition.participation_status " + + "and exists (select 1 from competition.live_evaluation_segments s " + + "join performance.bot_snapshots ps on ps.bot_id = p.bot_id " + + "and ps.snapshot_type = 'LEADERBOARD_CUTOFF'::performance.snapshot_type " + + "and ps.evaluated_at = s.ends_at and ps.source_event_sequence = s.end_event_sequence " + + "where s.id = ? and s.participation_id = p.id and s.finalized_at is not null)", + request.evaluationSegmentId(), request.participationId(), request.evaluationSegmentId()); + if (updated == 0) { + String status = (String) dsl.fetchValue( + "select status::text from competition.participations where id = ?", + request.participationId()); + if ("COMPLETED".equals(status)) { + return; + } + throw new IllegalStateException("finalized evaluation could not become COMPLETED"); + } + Number sequence = (Number) dsl.fetchValue( + "select coalesce(max(event_sequence), 0) + 1 from competition.participation_events " + + "where participation_id = ?", + request.participationId()); + UUID eventId = UUID.nameUUIDFromBytes( + ("room-evaluation-completed.v1:" + request.participationId()) + .getBytes(StandardCharsets.UTF_8)); + dsl.execute( + "insert into competition.participation_events " + + "(id, participation_id, event_sequence, event_type, occurred_at, payload_document) " + + "values (?, ?, ?, 'EVALUATION_COMPLETED', ?::timestamptz, " + + "jsonb_build_object('evaluationSegmentId', ?::text)) on conflict (id) do nothing", + eventId, request.participationId(), sequence.intValue(), completed, + request.evaluationSegmentId()); + } + + @Override + @Transactional(readOnly = true) + public Optional loadReadyResult(UUID roomId) { + Record room = dsl.fetchOne( + "select r.id, rs.evaluation_ends_at, st.id as template_id, st.template_code, " + + "st.version, st.rules_document::text as rules_document, st.rules_hash, " + + "st.published_at, st.retired_at, " + + "(select count(*) from competition.participations p where p.room_id = r.id " + + "and p.status = 'COMPLETED'::competition.participation_status) " + + "as participation_count " + + "from competition.rooms r " + + "join competition.room_schedules rs on rs.room_id = r.id " + + "join competition.room_rules rr on rr.room_id = r.id " + + "join competition.scoring_template_versions st on st.id = rr.scoring_template_version_id " + + "where r.id = ? and r.competition_type = 'LIVE_PAPER'::competition.competition_type " + + "and r.status = 'ENDED'::competition.room_status " + + "and not exists (select 1 from competition.leaderboard_snapshots ls " + + "where ls.room_id = r.id and ls.status = 'FINAL'::competition.leaderboard_status)", + roomId); + if (room == null) { + return Optional.empty(); + } + var rows = dsl.fetch( + "select p.id as participation_id, s.id as segment_id, ps.id as performance_id, " + + "extract(epoch from (s.ends_at - s.starts_at))::bigint as scheduled_seconds, " + + "extract(epoch from (s.ends_at - s.starts_at))::bigint as operation_seconds, " + + "(select count(*) from trading.fills f where f.bot_id = p.bot_id " + + "and f.occurred_at >= s.starts_at and f.occurred_at < s.ends_at)::int as fill_count, " + + "lrr.minimum_operation_seconds, lrr.minimum_fill_count " + + "from competition.participations p " + + "join competition.live_evaluation_segments s on s.participation_id = p.id " + + "join performance.bot_snapshots ps on ps.bot_id = p.bot_id " + + "and ps.snapshot_type = 'LEADERBOARD_CUTOFF'::performance.snapshot_type " + + "and ps.evaluated_at = s.ends_at and ps.source_event_sequence = s.end_event_sequence " + + "join competition.live_room_rules lrr on lrr.room_id = p.room_id " + + "where p.room_id = ? and p.status = 'COMPLETED'::competition.participation_status " + + "and s.segment_type = 'OFFICIAL_EVALUATION' and s.finalized_at is not null " + + "order by p.id", + roomId); + int participationCount = room.get("participation_count", Integer.class); + if (rows.size() != participationCount || rows.isEmpty()) { + return Optional.empty(); + } + List candidates = rows.map(row -> + new RoomFinalizationCandidateSource( + row.get("participation_id", UUID.class), + row.get("segment_id", UUID.class), + row.get("performance_id", UUID.class), + row.get("scheduled_seconds", Long.class), + row.get("operation_seconds", Long.class), + row.get("fill_count", Integer.class), + row.get("minimum_operation_seconds", Long.class), + row.get("minimum_fill_count", Integer.class))); + OffsetDateTime retired = room.get("retired_at", OffsetDateTime.class); + var template = new ScoringTemplateCatalogRecord( + room.get("template_id", UUID.class), + room.get("template_code", String.class), + room.get("version", String.class), + room.get("rules_document", String.class), + room.get("rules_hash", String.class), + room.get("published_at", OffsetDateTime.class).toInstant(), + retired == null ? null : retired.toInstant()); + return Optional.of(new RoomFinalizationSource( + roomId, + room.get("evaluation_ends_at", OffsetDateTime.class).toInstant(), + template, + candidates)); + } + + private static OffsetDateTime utc(Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } +} diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/CanonicalVirtualLiquidationQuoteAdapterIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/CanonicalVirtualLiquidationQuoteAdapterIntegrationTest.java index 5036f5a1..54ef7584 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/CanonicalVirtualLiquidationQuoteAdapterIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/CanonicalVirtualLiquidationQuoteAdapterIntegrationTest.java @@ -197,6 +197,23 @@ void aPositionCarriedIntoTheSegmentIsMarkedByItsLatestOfficialPrice() { assertThat(quote.grossCostAmount()).isEqualByComparingTo("0"); } + @Test + void aClosedMarketSegmentWithNoFillsStillProducesCanonicalFlatEvidence() { + jdbc.execute("truncate table trading.fills, trading.orders cascade"); + jdbc.update("delete from bot.bot_events where bot_id = ?", BOT); + + VirtualLiquidationQuote quote = adapter.load(context()); + VirtualLiquidationPerformance performance = + new VirtualLiquidationPerformanceCalculator().calculate(context(), quote); + + assertThat(quote.sourceEventSequence()).isEqualTo(1L); + assertThat(quote.liquidatedPositionCount()).isZero(); + assertThat(quote.currentCashAmount()).isEqualByComparingTo("1000"); + assertThat(quote.netLiquidationCashDelta()).isEqualByComparingTo("0"); + assertThat(performance.equityAmount()).isEqualByComparingTo("1000"); + assertThat(performance.totalReturnPct()).isEqualByComparingTo("0"); + } + private static VirtualLiquidationContext context() { return new VirtualLiquidationContext( ROOM, PARTICIPATION, BOT, SEGMENT, STARTS_AT, ENDS_AT, 1L, diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/PrivateContinuationTransitionPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/PrivateContinuationTransitionPersistenceIntegrationTest.java index 78c26e9a..231afd51 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/PrivateContinuationTransitionPersistenceIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/PrivateContinuationTransitionPersistenceIntegrationTest.java @@ -75,7 +75,7 @@ void prepare() { jdbc.update("delete from trading.fee_policy_versions where id = ?", FEE_ID); jdbc.update("delete from trading.buying_power_buffer_policy_versions where id = ?", BUFFER_ID); jdbc.execute("truncate table identity.account_lifecycle_command_receipts, identity.account_lifecycle_events cascade"); - jdbc.update("delete from identity.accounts where id in (?, ?)", OWNER_ID, CREATOR_ID); + jdbc.update("delete from identity.accounts where id in (?, ?, ?)", OWNER_ID, CREATOR_ID, FAILED_OWNER_ID); seedReadyCandidate(); } @@ -209,6 +209,20 @@ void finalResultLocksTheTerminalParticipationChoiceForTheContinuationHandoff() { jdbc.update("delete from competition.leaderboard_snapshots"); jdbc.update("update competition.participations set action_locked_at = null where id = ?", PARTICIPATION_ID); jdbc.update("update competition.rooms set access_type = 'SECRET' where id = ?", ROOM_ID); + jdbc.update("insert into identity.accounts (id, lifecycle_status) values (?, 'ACTIVE')", FAILED_OWNER_ID); + jdbc.update( + "insert into bot.bots " + + "(id, owner_account_id, mode, name, lifecycle_status, lifecycle_changed_at, " + + "execution_eligible_from, created_at, edit_sequence, updated_at) " + + "values (?, ?, 'BASIC', 'Failed room bot', 'STOPPED', ?, ?, ?, 0, ?)", + FAILED_BOT_ID, FAILED_OWNER_ID, utc(ENDED_AT), utc(CUTOFF), utc(CUTOFF), utc(ENDED_AT)); + jdbc.update( + "insert into competition.participations " + + "(id, room_id, bot_id, owner_account_id, anonymous_alias, status, joined_at, " + + "evaluation_started_at, evaluation_finished_at, evaluation_failure_code) " + + "values (?, ?, ?, ?, 'failed-room-bot', 'EVALUATION_FAILED', ?, ?, ?, 'LEDGER_OPEN_FAILED')", + FAILED_PARTICIPATION_ID, ROOM_ID, FAILED_BOT_ID, FAILED_OWNER_ID, + utc(CUTOFF.minusSeconds(600)), utc(CUTOFF.minusSeconds(600)), utc(CUTOFF)); var result = new FinalRoomResult( FINAL_SNAPSHOT_ID, ROOM_ID, TEMPLATE_ID, CUTOFF, "sha256:result", ENDED_AT, List.of(new FinalLeaderboardEntry( @@ -229,6 +243,7 @@ void finalResultLocksTheTerminalParticipationChoiceForTheContinuationHandoff() { .extracting(row -> List.of(row.get("account_id"), row.get("eligibility_basis"))) .containsExactly( List.of(OWNER_ID, "ACTIVE_PARTICIPANT"), + List.of(FAILED_OWNER_ID, "ACTIVE_PARTICIPANT"), List.of(CREATOR_ID, "CREATOR")); } @@ -428,6 +443,9 @@ private static UUID id(int suffix) { private static final UUID FEE_ID = id(9); private static final UUID BUFFER_ID = id(10); private static final UUID CREATOR_ID = id(11); + private static final UUID FAILED_OWNER_ID = id(12); + private static final UUID FAILED_BOT_ID = id(13); + private static final UUID FAILED_PARTICIPATION_ID = id(14); @SpringBootConfiguration @EnableAutoConfiguration diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/VirtualLiquidationPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/VirtualLiquidationPersistenceIntegrationTest.java index 85392f21..a425298e 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/VirtualLiquidationPersistenceIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/VirtualLiquidationPersistenceIntegrationTest.java @@ -3,8 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.idea2strategy.backend.application.competition.FinalRoomResultService; +import com.idea2strategy.backend.application.competition.RoomFinalizationService; import com.idea2strategy.backend.application.competition.ScoringEvidenceRequest; import com.idea2strategy.backend.application.competition.ScoringEvidenceService; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogService; import com.idea2strategy.backend.application.competition.VirtualLiquidationConflictException; import com.idea2strategy.backend.application.competition.VirtualLiquidationPerformanceCalculator; import com.idea2strategy.backend.application.competition.VirtualLiquidationQuote; @@ -58,6 +62,10 @@ static void databaseProperties(DynamicPropertyRegistry registry) { @Autowired VirtualLiquidationJooqAdapter adapter; @Autowired ScoringEvidenceJooqAdapter scoringEvidenceAdapter; + @Autowired RoomFinalizationWorkJooqAdapter finalizationWork; + @Autowired FinalRoomResultJooqAdapter finalResults; + @Autowired ScoringTemplateCatalogJooqQueryAdapter scoringTemplates; + private final ObjectMapper mapper = new ObjectMapper(); @Autowired JdbcTemplate jdbc; private VirtualLiquidationService service; @@ -69,7 +77,10 @@ void prepare() { jdbc.update("delete from performance.bot_snapshots"); jdbc.update("delete from performance.bot_current_projections"); jdbc.update("delete from competition.live_evaluation_segments"); + jdbc.update("delete from competition.participation_events"); jdbc.update("delete from competition.participations"); + jdbc.update("delete from competition.live_room_rules"); + jdbc.update("delete from competition.room_schedules"); jdbc.update("delete from competition.room_rules"); jdbc.update("delete from competition.rooms"); jdbc.update("delete from bot.bots"); @@ -84,7 +95,10 @@ void prepare() { jdbc.update( "insert into competition.scoring_template_versions " + "(id, template_code, version, rules_document, rules_hash, published_at) " - + "values (?, 'TOTAL_RETURN', '1', '{}'::jsonb, ?, ?)", + + "values (?, 'SINGLE_TOTAL_RETURN_V1', '1', " + + "'{\"kind\":\"SINGLE\",\"calculationRulesVersion\":\"official-room-scoring.v1\"," + + "\"components\":[{\"metric\":\"TOTAL_RETURN\",\"direction\":\"HIGHER_IS_BETTER\"," + + "\"coefficient\":1.0}],\"adjustments\":[]}'::jsonb, ?, ?)", TEMPLATE_ID, "sha256:" + "1".repeat(64), publishedAt); jdbc.update( "insert into trading.fee_policy_versions " @@ -109,6 +123,18 @@ void prepare() { + "rules_hash, locked_at) values (?, ?, 100000, 10, 2, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, " + "?, 5, ?, 'v1', ?, ?)", ROOM_ID, TEMPLATE_ID, FEE_ID, BUFFER_ID, ROOM_RULES_HASH, publishedAt); + jdbc.update( + "insert into competition.room_schedules " + + "(room_id, recruitment_opens_at, participation_opens_at, evaluation_starts_at, " + + "participation_closes_at, evaluation_ends_at, finalization_deadline_at, timezone_name) " + + "values (?, ?, ?, ?, ?, ?, ?, 'UTC')", + ROOM_ID, utc(START.minusSeconds(120)), utc(START.minusSeconds(120)), utc(START), + utc(START), utc(CUTOFF), utc(CUTOFF.plusSeconds(300))); + jdbc.update( + "insert into competition.live_room_rules " + + "(room_id, stopped_bot_slot_policy, minimum_operation_seconds, minimum_fill_count) " + + "values (?, 'COUNT_UNTIL_END', 0, 0)", + ROOM_ID); jdbc.update( "insert into bot.bots " + "(id, owner_account_id, mode, name, lifecycle_status, lifecycle_changed_at, " @@ -252,6 +278,65 @@ void corruptedStoredSnapshotDocumentConflictsOnRetry() { .hasMessageContaining("different evidence"); } + @Test + void scheduledFinalizationCompletesEvidencePublishesOneFinalResultAndConverges() { + jdbc.update( + "insert into bot.bots " + + "(id, owner_account_id, mode, name, lifecycle_status, lifecycle_changed_at, " + + "execution_eligible_from, created_at, edit_sequence, updated_at) " + + "values (?, ?, 'BASIC', 'Failed evaluation bot', 'STOPPED', ?, ?, ?, 0, ?)", + FAILED_BOT_ID, OWNER_ID, utc(CUTOFF), utc(START), utc(START), utc(CUTOFF)); + jdbc.update( + "insert into competition.participations " + + "(id, room_id, bot_id, owner_account_id, anonymous_alias, status, joined_at, " + + "evaluation_started_at, evaluation_finished_at, evaluation_failure_code) " + + "values (?, ?, ?, ?, 'anonymous-failed', " + + "'EVALUATION_FAILED'::competition.participation_status, ?, ?, ?, 'LEDGER_OPEN_FAILED')", + FAILED_PARTICIPATION_ID, ROOM_ID, FAILED_BOT_ID, OWNER_ID, + utc(START.minusSeconds(120)), utc(START), utc(CUTOFF)); + jdbc.update( + "update competition.rooms set status = 'ENDED'::competition.room_status, ended_at = ? where id = ?", + utc(CUTOFF), ROOM_ID); + Clock clock = Clock.fixed(FINALIZED_AT, ZoneOffset.UTC); + var finalization = new RoomFinalizationService( + finalizationWork, + service, + new ScoringEvidenceService(scoringEvidenceAdapter), + new FinalRoomResultService(finalResults, clock), + new ScoringTemplateCatalogService(scoringTemplates, clock, mapper), + clock, + mapper); + + var first = finalization.run(10); + + assertThat(first.roomsAttempted()).isOne(); + assertThat(first.roomsFinalized()).isOne(); + assertThat(first.participationsFinalized()).isOne(); + assertThat(first.failures()).isEmpty(); + assertThat(jdbc.queryForObject( + "select status::text from competition.participations where id = ?", + String.class, PARTICIPATION_ID)).isEqualTo("COMPLETED"); + assertThat(jdbc.queryForObject( + "select count(*) from competition.leaderboard_snapshots " + + "where room_id = ? and status = 'FINAL'", + Integer.class, ROOM_ID)).isOne(); + assertThat(jdbc.queryForObject( + "select count(*) from competition.leaderboard_entries le " + + "join competition.leaderboard_snapshots ls on ls.id = le.snapshot_id " + + "where ls.room_id = ? and le.performance_snapshot_id = ? " + + "and le.backtest_aggregate_result_id is null", + Integer.class, ROOM_ID, snapshotId())).isOne(); + + var retry = finalization.run(10); + assertThat(retry.roomsAttempted()).isZero(); + assertThat(jdbc.queryForObject( + "select count(*) from competition.leaderboard_snapshots where room_id = ?", + Integer.class, ROOM_ID)).isOne(); + assertThat(jdbc.queryForObject( + "select count(*) from performance.bot_snapshots where bot_id = ?", + Integer.class, BOT_ID)).isOne(); + } + private VirtualLiquidationRequest request() { return new VirtualLiquidationRequest(PARTICIPATION_ID, SEGMENT_ID); } @@ -312,9 +397,17 @@ private static UUID id(int suffix) { private static final UUID FEE_ID = id(6); private static final UUID BUFFER_ID = id(7); private static final UUID SEGMENT_ID = id(8); + private static final UUID FAILED_BOT_ID = id(9); + private static final UUID FAILED_PARTICIPATION_ID = id(10); @SpringBootConfiguration @EnableAutoConfiguration - @Import({VirtualLiquidationJooqAdapter.class, ScoringEvidenceJooqAdapter.class}) + @Import({ + VirtualLiquidationJooqAdapter.class, + ScoringEvidenceJooqAdapter.class, + RoomFinalizationWorkJooqAdapter.class, + FinalRoomResultJooqAdapter.class, + ScoringTemplateCatalogJooqQueryAdapter.class + }) static class TestApplication {} }