1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 08:29:36 +01:00

fix(production): N+1-Query in traceForward durch Level-by-Level BFS ersetzen

traceForward aus BatchTraceabilityService in BatchRepository verschoben.
Statt einer Query pro Knoten (N+1) wird jetzt eine IN(:parentIds)-Query
pro Tiefenebene ausgeführt (max. maxDepth Queries statt N).
This commit is contained in:
Sebastian Frick 2026-02-26 19:42:44 +01:00
parent ddb674d618
commit 8948103957
6 changed files with 130 additions and 174 deletions

View file

@ -31,5 +31,7 @@ public interface BatchRepository {
Result<RepositoryError, List<Batch>> findByInputBatchId(BatchId inputBatchId);
Result<RepositoryError, List<TracedBatch>> traceForward(BatchId startBatchId, int maxDepth);
Result<RepositoryError, Void> save(Batch batch);
}

View file

@ -2,11 +2,7 @@ package de.effigenix.domain.production;
import de.effigenix.shared.common.Result;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class BatchTraceabilityService {
@ -33,35 +29,15 @@ public class BatchTraceabilityService {
}
}
List<TracedBatch> result = new ArrayList<>();
Set<String> visited = new HashSet<>();
visited.add(startBatchId.value());
record BfsEntry(BatchId batchId, int depth) {}
var queue = new ArrayDeque<BfsEntry>();
queue.add(new BfsEntry(startBatchId, 0));
while (!queue.isEmpty()) {
var entry = queue.poll();
if (entry.depth() >= maxDepth) {
continue;
}
switch (batchRepository.findByInputBatchId(entry.batchId())) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var children) -> {
int childDepth = entry.depth() + 1;
for (Batch child : children) {
if (visited.add(child.id().value())) {
result.add(new TracedBatch(child, childDepth));
queue.add(new BfsEntry(child.id(), childDepth));
}
}
}
}
if (maxDepth <= 0) {
return Result.success(List.of());
}
return Result.success(result);
return switch (batchRepository.traceForward(startBatchId, maxDepth)) {
case Result.Failure(var err) ->
Result.failure(new BatchError.RepositoryFailure(err.message()));
case Result.Success(var traced) ->
Result.success(traced);
};
}
}

View file

@ -17,6 +17,8 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@ -210,6 +212,44 @@ public class JdbcBatchRepository implements BatchRepository {
}
}
@Override
public Result<RepositoryError, List<TracedBatch>> traceForward(BatchId startBatchId, int maxDepth) {
try {
String startId = startBatchId.value();
var results = new ArrayList<TracedBatch>();
var visited = new HashSet<String>();
visited.add(startId);
var currentLevel = List.of(startId);
for (int depth = 1; depth <= maxDepth && !currentLevel.isEmpty(); depth++) {
int currentDepth = depth;
var children = jdbc.sql("""
SELECT DISTINCT b.*
FROM batches b
JOIN batch_consumptions bc ON b.id = bc.batch_id
WHERE bc.input_batch_id IN (:parentIds)
""")
.param("parentIds", currentLevel)
.query(this::mapBatchRow)
.list();
var nextLevel = new ArrayList<String>();
for (Batch child : children) {
if (visited.add(child.id().value())) {
results.add(new TracedBatch(child, currentDepth));
nextLevel.add(child.id().value());
}
}
currentLevel = nextLevel;
}
return Result.success(results);
} catch (Exception e) {
logger.trace("Database error in traceForward", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Void> save(Batch batch) {
try {
@ -294,7 +334,7 @@ public class JdbcBatchRepository implements BatchRepository {
.query((rs, rowNum) -> rs.getString("id"))
.list();
var existingIdSet = new java.util.HashSet<>(existingIds);
var existingIdSet = new HashSet<>(existingIds);
for (Consumption c : batch.consumptions()) {
if (!existingIdSet.contains(c.id().value())) {

View file

@ -6,6 +6,7 @@ import de.effigenix.domain.production.BatchNumber;
import de.effigenix.domain.production.BatchRepository;
import de.effigenix.domain.production.BatchStatus;
import de.effigenix.domain.production.RecipeId;
import de.effigenix.domain.production.TracedBatch;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import org.springframework.context.annotation.Profile;
@ -77,6 +78,11 @@ public class StubBatchRepository implements BatchRepository {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<TracedBatch>> traceForward(BatchId startBatchId, int maxDepth) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, Void> save(Batch batch) {
return Result.failure(STUB_ERROR);

View file

@ -96,7 +96,7 @@ class BatchTraceabilityServiceFuzzTest {
/**
* In-memory BatchRepository that serves a pre-built graph.
* Only findById and findByInputBatchId are implemented (the rest throw).
* Implements traceForward with BFS (mirrors the contract of the recursive CTE).
*/
private static class InMemoryGraphRepository implements BatchRepository {
@ -122,6 +122,33 @@ class BatchTraceabilityServiceFuzzTest {
return Result.success(children.stream().map(BatchTraceabilityServiceFuzzTest::makeBatch).toList());
}
@Override
public Result<RepositoryError, List<TracedBatch>> traceForward(BatchId startBatchId, int maxDepth) {
List<TracedBatch> result = new ArrayList<>();
Set<String> visited = new HashSet<>();
visited.add(startBatchId.value());
record BfsEntry(String batchId, int depth) {}
var queue = new ArrayDeque<BfsEntry>();
queue.add(new BfsEntry(startBatchId.value(), 0));
while (!queue.isEmpty()) {
var entry = queue.poll();
if (entry.depth() >= maxDepth) continue;
var children = adjacency.getOrDefault(entry.batchId(), List.of());
int childDepth = entry.depth() + 1;
for (String childId : children) {
if (visited.add(childId)) {
result.add(new TracedBatch(makeBatch(childId), childDepth));
queue.add(new BfsEntry(childId, childDepth));
}
}
}
return Result.success(result);
}
// --- Remaining methods are not used by BatchTraceabilityService ---
@Override public Result<RepositoryError, List<Batch>> findAll() { throw new UnsupportedOperationException(); }

View file

@ -56,88 +56,6 @@ class BatchTraceabilityServiceTest {
@DisplayName("traceForward")
class TraceForward {
@Test
@DisplayName("should return empty list when no downstream batches exist")
void should_ReturnEmptyList_When_NoDownstreamBatches() {
var startId = BatchId.of("start-batch");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start-batch"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of()));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should return direct downstream batches at depth 1")
void should_ReturnDirectDownstream_AtDepth1() {
var startId = BatchId.of("start-batch");
var child1 = sampleBatch("child-1");
var child2 = sampleBatch("child-2");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start-batch"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(child1, child2)));
when(batchRepository.findByInputBatchId(BatchId.of("child-1"))).thenReturn(Result.success(List.of()));
when(batchRepository.findByInputBatchId(BatchId.of("child-2"))).thenReturn(Result.success(List.of()));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).hasSize(2);
assertThat(traced).allMatch(t -> t.depth() == 1);
assertThat(traced).extracting(t -> t.batch().id().value())
.containsExactlyInAnyOrder("child-1", "child-2");
}
@Test
@DisplayName("should traverse multi-level chain with correct depths")
void should_TraverseMultiLevel_WithCorrectDepths() {
var startId = BatchId.of("start");
var level1 = sampleBatch("level-1");
var level2 = sampleBatch("level-2");
var level3 = sampleBatch("level-3");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(level1)));
when(batchRepository.findByInputBatchId(BatchId.of("level-1"))).thenReturn(Result.success(List.of(level2)));
when(batchRepository.findByInputBatchId(BatchId.of("level-2"))).thenReturn(Result.success(List.of(level3)));
when(batchRepository.findByInputBatchId(BatchId.of("level-3"))).thenReturn(Result.success(List.of()));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).hasSize(3);
assertThat(traced.get(0).batch().id().value()).isEqualTo("level-1");
assertThat(traced.get(0).depth()).isEqualTo(1);
assertThat(traced.get(1).batch().id().value()).isEqualTo("level-2");
assertThat(traced.get(1).depth()).isEqualTo(2);
assertThat(traced.get(2).batch().id().value()).isEqualTo("level-3");
assertThat(traced.get(2).depth()).isEqualTo(3);
}
@Test
@DisplayName("should detect cycles and terminate without endless loop")
void should_DetectCycles_AndTerminate() {
var startId = BatchId.of("start");
var child = sampleBatch("child");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(child)));
// child references back to start cycle
when(batchRepository.findByInputBatchId(BatchId.of("child")))
.thenReturn(Result.success(List.of(sampleBatch("start"))));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).hasSize(1);
assertThat(traced.get(0).batch().id().value()).isEqualTo("child");
}
@Test
@DisplayName("should fail with BatchNotFound when start batch does not exist")
void should_FailWithBatchNotFound_When_StartBatchDoesNotExist() {
@ -148,26 +66,7 @@ class BatchTraceabilityServiceTest {
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
}
@Test
@DisplayName("should stop at max depth limit")
void should_StopAtMaxDepth() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
var level1 = sampleBatch("level-1");
var level2 = sampleBatch("level-2");
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(level1)));
when(batchRepository.findByInputBatchId(BatchId.of("level-1"))).thenReturn(Result.success(List.of(level2)));
var result = service.traceForward(startId, 2);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).hasSize(2);
assertThat(traced).extracting(TracedBatch::depth).containsExactly(1, 2);
verify(batchRepository, never()).traceForward(any(), anyInt());
}
@Test
@ -181,46 +80,40 @@ class BatchTraceabilityServiceTest {
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
verify(batchRepository, never()).traceForward(any(), anyInt());
}
@Test
@DisplayName("should fail with RepositoryFailure when findByInputBatchId returns error")
void should_FailWithRepositoryFailure_When_FindByInputBatchIdReturnsError() {
@DisplayName("should delegate to repository traceForward when start batch exists")
void should_DelegateToRepository_When_StartBatchExists() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.findByInputBatchId(startId))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
var result = service.traceForward(startId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should deduplicate shared child in diamond graph")
void should_DeduplicateSharedChild_InDiamondGraph() {
// A B, A C, B D, C D D appears only once
var startId = BatchId.of("A");
var batchB = sampleBatch("B");
var batchC = sampleBatch("C");
var batchD = sampleBatch("D");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("A"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(batchB, batchC)));
when(batchRepository.findByInputBatchId(BatchId.of("B"))).thenReturn(Result.success(List.of(batchD)));
when(batchRepository.findByInputBatchId(BatchId.of("C"))).thenReturn(Result.success(List.of(sampleBatch("D"))));
when(batchRepository.findByInputBatchId(BatchId.of("D"))).thenReturn(Result.success(List.of()));
var traced = List.of(
new TracedBatch(sampleBatch("child-1"), 1),
new TracedBatch(sampleBatch("child-2"), 1)
);
when(batchRepository.traceForward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH))
.thenReturn(Result.success(traced));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).extracting(t -> t.batch().id().value())
.containsExactlyInAnyOrder("B", "C", "D");
// D must appear only once despite being reachable from B and C
assertThat(traced.stream().filter(t -> t.batch().id().value().equals("D")).count())
.isEqualTo(1);
assertThat(result.unsafeGetValue()).hasSize(2);
verify(batchRepository).traceForward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH);
}
@Test
@DisplayName("should pass custom maxDepth to repository")
void should_PassCustomMaxDepth_ToRepository() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.traceForward(startId, 5)).thenReturn(Result.success(List.of()));
var result = service.traceForward(startId, 5);
assertThat(result.isSuccess()).isTrue();
verify(batchRepository).traceForward(startId, 5);
}
@Test
@ -233,23 +126,35 @@ class BatchTraceabilityServiceTest {
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
verify(batchRepository, never()).traceForward(any(), anyInt());
}
@Test
@DisplayName("should not include start batch in result")
void should_NotIncludeStartBatch_InResult() {
@DisplayName("should fail with RepositoryFailure when traceForward returns error")
void should_FailWithRepositoryFailure_When_TraceForwardReturnsError() {
var startId = BatchId.of("start");
var child = sampleBatch("child");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(child)));
when(batchRepository.findByInputBatchId(BatchId.of("child"))).thenReturn(Result.success(List.of()));
when(batchRepository.traceForward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
var result = service.traceForward(startId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should return empty list when no downstream batches exist")
void should_ReturnEmptyList_When_NoDownstreamBatches() {
var startId = BatchId.of("start-batch");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start-batch"))));
when(batchRepository.traceForward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH))
.thenReturn(Result.success(List.of()));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).extracting(t -> t.batch().id().value())
.doesNotContain("start");
assertThat(result.unsafeGetValue()).isEmpty();
}
}
}