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:
parent
ddb674d618
commit
8948103957
6 changed files with 130 additions and 174 deletions
|
|
@ -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(); }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue