1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 13:49: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

@ -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();
}
}
}