diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b74498..446487d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,17 @@ All notable changes to Reqover are documented in this file.
### Added
+- **A configurable eviction policy for the in-memory store.**
+ `reqover.mvc.snapshot-eviction` and `reqover.webflux.snapshot-eviction`
+ choose what happens at `max-snapshots`: `oldest-first` (the default, and the
+ only behaviour until now) or `reject-when-full`, which keeps the first N
+ snapshots and ignores later flushes. The slot is reserved with a CAS, so
+ concurrent flushes cannot overshoot the bound.
+- **`CoverageStoreContract`**, an abstract JUnit suite in the `reqover-core`
+ tests that a second `CoverageStore` implementation extends to pin the SPI's
+ invariants — in particular that `snapshots()` stays stable while another
+ thread is flushing. (#14; contributed in #15 by @VedantMadane, the first
+ change to Reqover from outside the team)
- **Compatibility policy** ([docs/20_versioning_and_compatibility.md](docs/20_versioning_and_compatibility.md)):
what a version number promises, what counts as public API, how the report
schema evolves, and what happens when a release is broken. Maven Central is
diff --git a/docs/17_integration_guide.ko.md b/docs/17_integration_guide.ko.md
index 679cff0..8fa2bbf 100644
--- a/docs/17_integration_guide.ko.md
+++ b/docs/17_integration_guide.ko.md
@@ -161,10 +161,12 @@ Reqover가 읽는 속성 전부와, 지정하지 않았을 때 적용되는 기
| `reqover.mvc.enabled` | `true` | 요청 추적을 아예 붙일지 여부. `false`면 MVC 어댑터가 컨텍스트에 올라오지 않습니다 |
| `reqover.mvc.include-path-patterns` | `/**` | 인터셉터가 추적할 Ant 경로 패턴. 기본은 전체입니다 |
| `reqover.mvc.exclude-path-patterns` | `/reqover`, `/reqover/**`, `/error` | 추적에서 제외할 경로. 이 속성을 지정하면 기본 목록을 **대체**합니다 |
-| `reqover.mvc.max-snapshots` | `10000` | 기본 인메모리 저장소가 오래된 것을 지우기 전까지 보관하는 완료 요청 수. 내 `CoverageStore` 빈을 넣으면 무시됩니다 |
+| `reqover.mvc.max-snapshots` | `10000` | 기본 인메모리 저장소가 보관 정책을 적용하기 전까지 보관하는 완료 요청 수. 내 `CoverageStore` 빈을 넣으면 무시됩니다 |
+| `reqover.mvc.snapshot-eviction` | `oldest-first` | 상한에 닿았을 때의 동작: `oldest-first`는 가장 오래된 스냅샷을 지우고(기본), `reject-when-full`은 기존 창을 그대로 두고 새로 들어오는 것을 버립니다. 내 `CoverageStore` 빈을 넣으면 무시됩니다 |
| `reqover.webflux.enabled` | `true` | WebFlux 어댑터를 붙일지 여부. `false`면 Reactor 컨텍스트 자동 전달을 켜는 것도 건너뜁니다 |
| `reqover.webflux.exclude-path-prefixes` | `/reqover` | 추적에서 제외할 경로. Ant 패턴이 아니라 **앞부분 일치**로 비교합니다. 지정하면 기본 목록을 대체합니다 |
| `reqover.webflux.max-snapshots` | `10000` | `reqover.mvc.max-snapshots`와 같고, 리액티브 애플리케이션용입니다 |
+| `reqover.webflux.snapshot-eviction` | `oldest-first` | `reqover.mvc.snapshot-eviction`과 같고, 리액티브 애플리케이션용입니다 |
| `reqover.report.endpoint.enabled` | **`false`** | 내장 HTTP 리포트 엔드포인트를 등록할지 여부. 기본은 꺼짐 — [3단계](#3-리포트를-어떻게-볼지-정하기) 참고 |
| `reqover.report.endpoint.path` | `/reqover/report` | 엔드포인트의 기준 경로. 이 경로로 JSON이, 같은 경로에 `.html`을 붙인 경로로 HTML 리포트가 나갑니다 |
| `reqover.report.export.json-path` | *지정 안 함* | 애플리케이션 컨텍스트가 닫힐 때 JSON 리포트를 쓸 경로. 비워두면 JSON을 내보내지 않습니다 |
@@ -418,14 +420,20 @@ WebFlux라면 하나 더 — 한 API의 기록 안에 **서로 다른 스레드
### 보관 개수 조정
-기록은 메모리에만 남고 기본 상한이 10,000건입니다. 넘으면 오래된 것부터 지워집니다. `0.2.0`부터는 속성으로 조정합니다 — 빈을 만들 필요가 없습니다.
+기록은 메모리에만 남고 기본 상한이 10,000건입니다. 상한을 넘으면 가장 오래된 스냅샷을 지우거나(`oldest-first`, 기본), 기존 창을 그대로 두고 새로 들어오는 것을 버립니다(`reject-when-full`). 상한과 정책 모두 속성으로 조정합니다 — 빈을 만들 필요가 없습니다.
```properties
reqover.mvc.max-snapshots=50000
+reqover.mvc.snapshot-eviction=oldest-first
+# 긴 QA 세션에서 처음 N건을 남기고 싶다면:
+# reqover.mvc.snapshot-eviction=reject-when-full
# WebFlux라면
# reqover.webflux.max-snapshots=50000
+# reqover.webflux.snapshot-eviction=reject-when-full
```
+`CoverageStore`를 직접 구현한다면 `reqover-core` 테스트에 있는 추상 JUnit 계약(`CoverageStoreContract`)으로 같은 동작을 보장할 수 있습니다. 상속해서 `newStore()`가 내 저장소를 돌려주게 하고 테스트를 돌리면 됩니다.
+
### 저장소 교체하기
기록이 어디로 갈지는 `CoverageStore`가 SPI입니다. 이 타입의 빈을 정의하면 어댑터가 물러납니다 — 두 어댑터 모두 자기 저장소 빈에 `@ConditionalOnMissingBean(CoverageStore.class)`를 걸어 두었습니다. 그래서 스냅샷을 디스크나 데이터베이스에 쓰거나, 샘플링 규칙으로 버리는 구현을 넣을 수 있습니다.
diff --git a/docs/17_integration_guide.md b/docs/17_integration_guide.md
index 5755bf2..ac09eb8 100644
--- a/docs/17_integration_guide.md
+++ b/docs/17_integration_guide.md
@@ -161,10 +161,12 @@ Every property Reqover reads, with the default that applies when you leave it ou
| `reqover.mvc.enabled` | `true` | Whether request attribution is installed at all. `false` keeps the MVC adapter out of the context |
| `reqover.mvc.include-path-patterns` | `/**` | Ant path patterns the interceptor attributes. Defaults to everything |
| `reqover.mvc.exclude-path-patterns` | `/reqover`, `/reqover/**`, `/error` | Paths excluded from attribution. Setting this **replaces** the default list |
-| `reqover.mvc.max-snapshots` | `10000` | How many finished requests the default in-memory store retains before evicting the oldest. Ignored when you supply your own `CoverageStore` bean |
+| `reqover.mvc.max-snapshots` | `10000` | How many finished requests the default in-memory store retains before applying the eviction policy. Ignored when you supply your own `CoverageStore` bean |
+| `reqover.mvc.snapshot-eviction` | `oldest-first` | What happens at the bound: `oldest-first` drops the oldest snapshot (default), `reject-when-full` keeps the existing window and ignores new flushes. Ignored when you supply your own `CoverageStore` bean |
| `reqover.webflux.enabled` | `true` | Whether the WebFlux adapter is installed. `false` also skips enabling Reactor's automatic context propagation |
| `reqover.webflux.exclude-path-prefixes` | `/reqover` | Paths excluded from attribution, matched as **prefixes** (not Ant patterns). Setting this replaces the default list |
| `reqover.webflux.max-snapshots` | `10000` | Same as `reqover.mvc.max-snapshots`, for reactive applications |
+| `reqover.webflux.snapshot-eviction` | `oldest-first` | Same as `reqover.mvc.snapshot-eviction`, for reactive applications |
| `reqover.report.endpoint.enabled` | **`false`** | Whether the built-in HTTP report endpoint is registered. Off by default — see [step 3](#3-decide-how-you-read-the-report) |
| `reqover.report.endpoint.path` | `/reqover/report` | Base path for the endpoint. JSON is served here, and the HTML report at the same path with `.html` appended |
| `reqover.report.export.json-path` | *unset* | Where to write the JSON report when the application context closes. Unset or blank means no JSON export |
@@ -418,14 +420,20 @@ The most common failure is **"the report is empty"**, and the cause is usually `
### Adjusting retention
-Records live in memory only, with a default cap of 10,000 entries; beyond that the oldest are dropped. In `0.2.0` this is a property — no bean needed:
+Records live in memory only, with a default cap of 10,000 entries. Beyond that the store either drops the oldest snapshot (`oldest-first`, the default) or keeps the existing window and ignores new flushes (`reject-when-full`). Both the bound and the policy are properties — no bean needed:
```properties
reqover.mvc.max-snapshots=50000
+reqover.mvc.snapshot-eviction=oldest-first
+# Keep the first N for a long QA session instead of rolling:
+# reqover.mvc.snapshot-eviction=reject-when-full
# WebFlux:
# reqover.webflux.max-snapshots=50000
+# reqover.webflux.snapshot-eviction=reject-when-full
```
+A second `CoverageStore` implementation can pin the same behaviour with the abstract JUnit contract in `reqover-core` tests (`CoverageStoreContract`): extend it, return your store from `newStore()`, and run the suite.
+
### Replacing the store
`CoverageStore` is the SPI for where records go. Define a bean of that type and the adapters back off — both contribute their store with `@ConditionalOnMissingBean(CoverageStore.class)` — so you can write snapshots to disk, to a database, or drop them under a sampling rule:
diff --git a/reqover-core/src/main/java/io/reqover/core/InMemoryCoverageStore.java b/reqover-core/src/main/java/io/reqover/core/InMemoryCoverageStore.java
index 37bf593..df8ca8b 100644
--- a/reqover-core/src/main/java/io/reqover/core/InMemoryCoverageStore.java
+++ b/reqover-core/src/main/java/io/reqover/core/InMemoryCoverageStore.java
@@ -1,16 +1,20 @@
package io.reqover.core;
import java.util.List;
+import java.util.Objects;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Thread-safe {@link CoverageStore} that retains snapshots in heap.
*
- *
The store keeps at most {@code maxSnapshots} entries; once the bound is
- * reached, the oldest snapshots are evicted so a long-running application does
- * not grow memory without limit. Eviction is best-effort under heavy
- * concurrency, but the size stays close to the configured bound.
+ *
The store keeps at most {@code maxSnapshots} entries. What happens once
+ * that bound is reached is controlled by {@link SnapshotEvictionPolicy}:
+ * {@link SnapshotEvictionPolicy#OLDEST_FIRST} drops the oldest snapshot
+ * (the historical default), while {@link SnapshotEvictionPolicy#REJECT_WHEN_FULL}
+ * keeps the existing window and ignores further flushes. Eviction is
+ * best-effort under heavy concurrency, but the size stays close to the
+ * configured bound.
*/
public final class InMemoryCoverageStore implements CoverageStore {
/** Default retention bound, sized for local development and demo traffic. */
@@ -19,16 +23,22 @@ public final class InMemoryCoverageStore implements CoverageStore {
private final ConcurrentLinkedQueue completed = new ConcurrentLinkedQueue<>();
private final AtomicInteger size = new AtomicInteger();
private final int maxSnapshots;
+ private final SnapshotEvictionPolicy evictionPolicy;
public InMemoryCoverageStore() {
- this(DEFAULT_MAX_SNAPSHOTS);
+ this(DEFAULT_MAX_SNAPSHOTS, SnapshotEvictionPolicy.OLDEST_FIRST);
}
public InMemoryCoverageStore(int maxSnapshots) {
+ this(maxSnapshots, SnapshotEvictionPolicy.OLDEST_FIRST);
+ }
+
+ public InMemoryCoverageStore(int maxSnapshots, SnapshotEvictionPolicy evictionPolicy) {
if (maxSnapshots <= 0) {
throw new IllegalArgumentException("maxSnapshots must be positive: " + maxSnapshots);
}
this.maxSnapshots = maxSnapshots;
+ this.evictionPolicy = Objects.requireNonNull(evictionPolicy, "evictionPolicy");
}
/** The retention bound this store was built with. */
@@ -36,9 +46,30 @@ public int maxSnapshots() {
return maxSnapshots;
}
+ /** Policy applied when {@link #maxSnapshots()} is reached. */
+ public SnapshotEvictionPolicy evictionPolicy() {
+ return evictionPolicy;
+ }
+
@Override
public void flush(CoverageBucket bucket) {
- completed.add(bucket.snapshot());
+ CoverageBucketSnapshot snapshot = bucket.snapshot();
+ if (evictionPolicy == SnapshotEvictionPolicy.REJECT_WHEN_FULL) {
+ // Reserve a slot first so concurrent flushes cannot overshoot the bound.
+ int current = size.get();
+ while (current < maxSnapshots) {
+ if (size.compareAndSet(current, current + 1)) {
+ completed.add(snapshot);
+ return;
+ }
+ current = size.get();
+ }
+ // Store is full — drop the new snapshot (already taken so the bucket
+ // cannot mutate what was retained earlier).
+ return;
+ }
+
+ completed.add(snapshot);
if (size.incrementAndGet() > maxSnapshots && completed.poll() != null) {
size.decrementAndGet();
}
diff --git a/reqover-core/src/main/java/io/reqover/core/SnapshotEvictionPolicy.java b/reqover-core/src/main/java/io/reqover/core/SnapshotEvictionPolicy.java
new file mode 100644
index 0000000..950aa34
--- /dev/null
+++ b/reqover-core/src/main/java/io/reqover/core/SnapshotEvictionPolicy.java
@@ -0,0 +1,15 @@
+package io.reqover.core;
+
+/**
+ * How {@link InMemoryCoverageStore} behaves once {@code maxSnapshots} is reached.
+ *
+ * The Spring adapters bind {@code reqover.mvc.snapshot-eviction} and
+ * {@code reqover.webflux.snapshot-eviction} to these constants directly
+ * ({@code oldest-first}, {@code reject-when-full}); there is no separate parser.
+ */
+public enum SnapshotEvictionPolicy {
+ /** Drop the oldest retained snapshot to make room for the new one (default). */
+ OLDEST_FIRST,
+ /** Leave the store unchanged and ignore the newly flushed bucket. */
+ REJECT_WHEN_FULL
+}
diff --git a/reqover-core/src/test/java/io/reqover/core/CoverageStoreContract.java b/reqover-core/src/test/java/io/reqover/core/CoverageStoreContract.java
new file mode 100644
index 0000000..39af6ad
--- /dev/null
+++ b/reqover-core/src/test/java/io/reqover/core/CoverageStoreContract.java
@@ -0,0 +1,136 @@
+package io.reqover.core;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Reusable contract tests for {@link CoverageStore} implementations.
+ *
+ *
Subclass this and implement {@link #newStore()} to pin a second store
+ * against the same behaviour the in-memory implementation is held to.
+ */
+public abstract class CoverageStoreContract {
+
+ /** Fresh empty store under test. */
+ protected abstract CoverageStore newStore();
+
+ protected CoverageBucket bucket(String unitId) {
+ return new CoverageBucket(UnitInfo.httpRequest(unitId, "GET", "/orders/{id}"));
+ }
+
+ @Test
+ void flushedBucketAppearsInSnapshots() {
+ CoverageStore store = newStore();
+ CoverageBucket bucket = bucket("req-1");
+ bucket.record(10, 3);
+
+ store.flush(bucket);
+
+ assertEquals(1, store.snapshots().size());
+ assertTrue(store.snapshots().get(0).hasHit(10, 3));
+ assertEquals("req-1", store.snapshots().get(0).unitInfo().unitId());
+ }
+
+ @Test
+ void snapshotsReturnsCopySafeToIterateWhileAnotherThreadFlushes() throws Exception {
+ CoverageStore store = newStore();
+ store.flush(bucket("seed"));
+
+ List view = store.snapshots();
+ assertEquals(1, view.size());
+
+ int readers = 4;
+ int flushes = 40;
+ ExecutorService pool = Executors.newFixedThreadPool(readers + 1);
+ CyclicBarrier start = new CyclicBarrier(readers + 1);
+ CountDownLatch done = new CountDownLatch(readers + 1);
+ AtomicReference failure = new AtomicReference<>();
+
+ Future> writer = pool.submit(() -> {
+ try {
+ start.await(5, TimeUnit.SECONDS);
+ for (int i = 0; i < flushes; i++) {
+ store.flush(bucket("w-" + i));
+ }
+ } catch (Throwable t) {
+ failure.compareAndSet(null, t);
+ } finally {
+ done.countDown();
+ }
+ });
+
+ List> readerFutures = new ArrayList<>();
+ for (int r = 0; r < readers; r++) {
+ readerFutures.add(pool.submit(() -> {
+ try {
+ start.await(5, TimeUnit.SECONDS);
+ for (int i = 0; i < 200; i++) {
+ int sum = 0;
+ for (CoverageBucketSnapshot snapshot : view) {
+ sum += snapshot.unitInfo().unitId().length();
+ }
+ assertTrue(sum > 0);
+ // Fresh snapshots() must not be the same list instance.
+ assertNotSame(view, store.snapshots());
+ }
+ } catch (Throwable t) {
+ failure.compareAndSet(null, t);
+ } finally {
+ done.countDown();
+ }
+ }));
+ }
+
+ assertTrue(done.await(30, TimeUnit.SECONDS), "contract threads timed out");
+ writer.get(5, TimeUnit.SECONDS);
+ for (Future> future : readerFutures) {
+ future.get(5, TimeUnit.SECONDS);
+ }
+ pool.shutdownNow();
+ if (failure.get() != null) {
+ throw new AssertionError("contract concurrency failure", failure.get());
+ }
+ assertEquals(1, view.size(), "snapshot list returned earlier must stay stable");
+ }
+
+ @Test
+ void clearEmptiesTheStore() {
+ CoverageStore store = newStore();
+ store.flush(bucket("a"));
+ store.flush(bucket("b"));
+ assertFalse(store.snapshots().isEmpty());
+
+ store.clear();
+
+ assertTrue(store.snapshots().isEmpty());
+ }
+
+ @Test
+ void hitsAfterFlushDoNotMutateStoredSnapshot() {
+ CoverageStore store = newStore();
+ CoverageBucket bucket = bucket("req-live");
+ bucket.record(1, 1);
+ store.flush(bucket);
+
+ bucket.record(2, 2);
+
+ CoverageBucketSnapshot stored = store.snapshots().get(0);
+ assertTrue(stored.hasHit(1, 1));
+ assertFalse(stored.hasHit(2, 2), "post-flush hits must not mutate the retained snapshot");
+ assertTrue(bucket.hasHit(2, 2), "the live bucket may keep receiving hits");
+ }
+}
diff --git a/reqover-core/src/test/java/io/reqover/core/InMemoryCoverageStoreTest.java b/reqover-core/src/test/java/io/reqover/core/InMemoryCoverageStoreTest.java
index 448c7a4..55e7089 100644
--- a/reqover-core/src/test/java/io/reqover/core/InMemoryCoverageStoreTest.java
+++ b/reqover-core/src/test/java/io/reqover/core/InMemoryCoverageStoreTest.java
@@ -3,19 +3,12 @@
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
-class InMemoryCoverageStoreTest {
- @Test
- void flushesSnapshots() {
- InMemoryCoverageStore store = new InMemoryCoverageStore();
- CoverageBucket bucket = new CoverageBucket(UnitInfo.httpRequest("req-1", "GET", "/orders/{id}"));
- bucket.record(10, 3);
-
- store.flush(bucket);
-
- assertEquals(1, store.snapshots().size());
- assertTrue(store.snapshots().get(0).hasHit(10, 3));
+class InMemoryCoverageStoreTest extends CoverageStoreContract {
+ @Override
+ protected CoverageStore newStore() {
+ return new InMemoryCoverageStore();
}
@Test
@@ -29,14 +22,26 @@ void evictsOldestSnapshotsBeyondCapacity() {
assertEquals(2, store.snapshots().size());
assertEquals("req-2", store.snapshots().get(0).unitInfo().unitId());
assertEquals("req-3", store.snapshots().get(1).unitInfo().unitId());
+ assertEquals(SnapshotEvictionPolicy.OLDEST_FIRST, store.evictionPolicy());
+ }
+
+ @Test
+ void rejectWhenFullKeepsExistingWindow() {
+ InMemoryCoverageStore store =
+ new InMemoryCoverageStore(2, SnapshotEvictionPolicy.REJECT_WHEN_FULL);
+
+ store.flush(new CoverageBucket(UnitInfo.httpRequest("req-1", "GET", "/orders/{id}")));
+ store.flush(new CoverageBucket(UnitInfo.httpRequest("req-2", "GET", "/orders/{id}")));
+ store.flush(new CoverageBucket(UnitInfo.httpRequest("req-3", "GET", "/orders/{id}")));
+
+ assertEquals(2, store.snapshots().size());
+ assertEquals("req-1", store.snapshots().get(0).unitInfo().unitId());
+ assertEquals("req-2", store.snapshots().get(1).unitInfo().unitId());
+ assertEquals(SnapshotEvictionPolicy.REJECT_WHEN_FULL, store.evictionPolicy());
}
@Test
void rejectsNonPositiveCapacity() {
- org.junit.jupiter.api.Assertions.assertThrows(
- IllegalArgumentException.class,
- () -> new InMemoryCoverageStore(0)
- );
+ assertThrows(IllegalArgumentException.class, () -> new InMemoryCoverageStore(0));
}
}
-
diff --git a/reqover-spring-mvc/src/main/java/io/reqover/spring/mvc/ReqoverMvcConfiguration.java b/reqover-spring-mvc/src/main/java/io/reqover/spring/mvc/ReqoverMvcConfiguration.java
index 8ce438c..ef6499a 100644
--- a/reqover-spring-mvc/src/main/java/io/reqover/spring/mvc/ReqoverMvcConfiguration.java
+++ b/reqover-spring-mvc/src/main/java/io/reqover/spring/mvc/ReqoverMvcConfiguration.java
@@ -20,7 +20,7 @@ public class ReqoverMvcConfiguration {
@Bean
@ConditionalOnMissingBean(CoverageStore.class)
public CoverageStore reqoverCoverageStore(ReqoverMvcProperties properties) {
- return new InMemoryCoverageStore(properties.getMaxSnapshots());
+ return new InMemoryCoverageStore(properties.getMaxSnapshots(), properties.getSnapshotEviction());
}
@Bean
diff --git a/reqover-spring-mvc/src/main/java/io/reqover/spring/mvc/ReqoverMvcProperties.java b/reqover-spring-mvc/src/main/java/io/reqover/spring/mvc/ReqoverMvcProperties.java
index 15ccd0d..0fa1b9c 100644
--- a/reqover-spring-mvc/src/main/java/io/reqover/spring/mvc/ReqoverMvcProperties.java
+++ b/reqover-spring-mvc/src/main/java/io/reqover/spring/mvc/ReqoverMvcProperties.java
@@ -1,6 +1,7 @@
package io.reqover.spring.mvc;
import io.reqover.core.InMemoryCoverageStore;
+import io.reqover.core.SnapshotEvictionPolicy;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
@@ -16,6 +17,7 @@ public class ReqoverMvcProperties {
private List excludePathPatterns =
new ArrayList<>(List.of("/reqover", "/reqover/**", "/error"));
private int maxSnapshots = InMemoryCoverageStore.DEFAULT_MAX_SNAPSHOTS;
+ private SnapshotEvictionPolicy snapshotEviction = SnapshotEvictionPolicy.OLDEST_FIRST;
/** Whether request attribution is installed at all. */
public boolean isEnabled() {
@@ -49,8 +51,8 @@ public void setExcludePathPatterns(List excludePathPatterns) {
/**
* How many finished requests the default in-memory store retains before
- * evicting the oldest. Ignored when the application supplies its own
- * {@link io.reqover.core.CoverageStore} bean.
+ * applying {@link #getSnapshotEviction()}. Ignored when the application
+ * supplies its own {@link io.reqover.core.CoverageStore} bean.
*/
public int getMaxSnapshots() {
return maxSnapshots;
@@ -59,4 +61,19 @@ public int getMaxSnapshots() {
public void setMaxSnapshots(int maxSnapshots) {
this.maxSnapshots = maxSnapshots;
}
+
+ /**
+ * What the default in-memory store does once {@link #getMaxSnapshots()} is
+ * reached: drop the oldest snapshot, or reject new ones. Ignored when the
+ * application supplies its own {@link io.reqover.core.CoverageStore} bean.
+ */
+ public SnapshotEvictionPolicy getSnapshotEviction() {
+ return snapshotEviction;
+ }
+
+ public void setSnapshotEviction(SnapshotEvictionPolicy snapshotEviction) {
+ this.snapshotEviction = snapshotEviction == null
+ ? SnapshotEvictionPolicy.OLDEST_FIRST
+ : snapshotEviction;
+ }
}
diff --git a/reqover-spring-mvc/src/test/java/io/reqover/spring/mvc/ReqoverMvcAutoConfigurationTest.java b/reqover-spring-mvc/src/test/java/io/reqover/spring/mvc/ReqoverMvcAutoConfigurationTest.java
index 9d0fb62..6b648b9 100644
--- a/reqover-spring-mvc/src/test/java/io/reqover/spring/mvc/ReqoverMvcAutoConfigurationTest.java
+++ b/reqover-spring-mvc/src/test/java/io/reqover/spring/mvc/ReqoverMvcAutoConfigurationTest.java
@@ -50,6 +50,22 @@ void sizesTheDefaultStoreFromTheConfiguredBound() {
});
}
+ @Test
+ void configuresSnapshotEvictionOnTheDefaultStore() {
+ contextRunner
+ .withPropertyValues(
+ "reqover.mvc.max-snapshots=3",
+ "reqover.mvc.snapshot-eviction=reject-when-full"
+ )
+ .run(context -> {
+ InMemoryCoverageStore store = (InMemoryCoverageStore) context.getBean(CoverageStore.class);
+ assertEquals(3, store.maxSnapshots());
+ assertEquals(
+ io.reqover.core.SnapshotEvictionPolicy.REJECT_WHEN_FULL,
+ store.evictionPolicy());
+ });
+ }
+
@Test
void backsOffWhenTheApplicationSuppliesItsOwnStore() {
contextRunner
diff --git a/reqover-spring-webflux/src/main/java/io/reqover/spring/webflux/ReqoverWebFluxConfiguration.java b/reqover-spring-webflux/src/main/java/io/reqover/spring/webflux/ReqoverWebFluxConfiguration.java
index 87dad65..29032b3 100644
--- a/reqover-spring-webflux/src/main/java/io/reqover/spring/webflux/ReqoverWebFluxConfiguration.java
+++ b/reqover-spring-webflux/src/main/java/io/reqover/spring/webflux/ReqoverWebFluxConfiguration.java
@@ -39,7 +39,7 @@ public void afterPropertiesSet() {
@Bean
@ConditionalOnMissingBean(CoverageStore.class)
public CoverageStore reqoverCoverageStore(ReqoverWebFluxProperties properties) {
- return new InMemoryCoverageStore(properties.getMaxSnapshots());
+ return new InMemoryCoverageStore(properties.getMaxSnapshots(), properties.getSnapshotEviction());
}
@Bean
diff --git a/reqover-spring-webflux/src/main/java/io/reqover/spring/webflux/ReqoverWebFluxProperties.java b/reqover-spring-webflux/src/main/java/io/reqover/spring/webflux/ReqoverWebFluxProperties.java
index 817dabb..19b6732 100644
--- a/reqover-spring-webflux/src/main/java/io/reqover/spring/webflux/ReqoverWebFluxProperties.java
+++ b/reqover-spring-webflux/src/main/java/io/reqover/spring/webflux/ReqoverWebFluxProperties.java
@@ -1,6 +1,7 @@
package io.reqover.spring.webflux;
import io.reqover.core.InMemoryCoverageStore;
+import io.reqover.core.SnapshotEvictionPolicy;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
@@ -14,6 +15,7 @@ public class ReqoverWebFluxProperties {
private boolean enabled = true;
private List excludePathPrefixes = new ArrayList<>(List.of("/reqover"));
private int maxSnapshots = InMemoryCoverageStore.DEFAULT_MAX_SNAPSHOTS;
+ private SnapshotEvictionPolicy snapshotEviction = SnapshotEvictionPolicy.OLDEST_FIRST;
/**
* Whether the adapter is installed. Turning it off also skips enabling
@@ -43,8 +45,8 @@ public void setExcludePathPrefixes(List excludePathPrefixes) {
/**
* How many finished requests the default in-memory store retains before
- * evicting the oldest. Ignored when the application supplies its own
- * {@link io.reqover.core.CoverageStore} bean.
+ * applying {@link #getSnapshotEviction()}. Ignored when the application
+ * supplies its own {@link io.reqover.core.CoverageStore} bean.
*/
public int getMaxSnapshots() {
return maxSnapshots;
@@ -53,4 +55,19 @@ public int getMaxSnapshots() {
public void setMaxSnapshots(int maxSnapshots) {
this.maxSnapshots = maxSnapshots;
}
+
+ /**
+ * What the default in-memory store does once {@link #getMaxSnapshots()} is
+ * reached: drop the oldest snapshot, or reject new ones. Ignored when the
+ * application supplies its own {@link io.reqover.core.CoverageStore} bean.
+ */
+ public SnapshotEvictionPolicy getSnapshotEviction() {
+ return snapshotEviction;
+ }
+
+ public void setSnapshotEviction(SnapshotEvictionPolicy snapshotEviction) {
+ this.snapshotEviction = snapshotEviction == null
+ ? SnapshotEvictionPolicy.OLDEST_FIRST
+ : snapshotEviction;
+ }
}