diff --git a/README.md b/README.md index fd90f65..0a1a8d6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,23 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml down `src/.env`는 커밋된 안전 기본값이라 별도 `.env.example`을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 [src/README.md](src/README.md)와 [docs/registries/env-keys.yaml](docs/registries/env-keys.yaml)에 있습니다. +### 프로파일별 데이터스토어 + +`bootstrap`은 컨테이너 경로(PostgreSQL)를 검증하는 첫 실행 진입점입니다. 일상 개발은 Docker 없이 돌리는 `local` 프로파일이며, 이때 데이터스토어는 H2 in-memory입니다. + +```bash +cd src +./gradlew :app-bootstrap:bootRun +``` + +| 프로파일 | 데이터스토어 | 스키마 소유자 | +| --- | --- | --- | +| `local` (bootRun 기본) | H2 in-memory | Hibernate `create-drop` | +| `dev` | PostgreSQL | Flyway | +| `prod` | PostgreSQL | Flyway | + +`local`은 wiring과 애플리케이션 동작을 검증하고, migration과 vendor 동작은 검증하지 않습니다. 프로파일별 설정은 [src/app-bootstrap/src/main/resources/](src/app-bootstrap/src/main/resources/)의 `application-{local,dev,prod}.yml`이, 상세 설명은 [src/README.md](src/README.md)가 소유합니다. + ## 새 프로젝트로 시작하기 이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 [AGENTS.md](AGENTS.md)의 "템플릿 재사용 체크리스트"에 있습니다. diff --git a/docker-compose.local.yml b/docker-compose.local.yml index e69b6f9..082c2a0 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -8,8 +8,9 @@ # - Starts a local PostgreSQL database for integration testing without Testcontainers. # - Wires the app environment to point at the local DB. # - Keeps read-only filesystem and memory limits from the base compose. -# - Does NOT expose the DB port publicly; app and db communicate on the -# internal `caskeleton-local` network only. +# - Publishes the DB on the loopback interface only, so a host-side run +# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the +# containerised app reaches over the internal `caskeleton-local` network. # ============================================================================= services: @@ -59,7 +60,17 @@ services: - type: volume source: caskeleton-db-data target: /var/lib/postgresql/data - # No host port: startup Flyway runs in the app container over the internal network. + # The containerised app reaches this over the internal network and needs no host port. A + # host-side run does: src/.env is the dotenv source bootRun reads, and its committed + # APP_DATASOURCE_URL is jdbc:postgresql://localhost:5433/ca_skeleton. With the port unpublished + # that default named an address nothing in the repository provisioned, so every bootRun died in + # the startup migration phase with a connection refusal. + # + # Bound to 127.0.0.1, never 0.0.0.0: the database is reachable from this machine and from + # nowhere else on the network. Host 5433 (not 5432) so a PostgreSQL already installed on the + # host keeps its conventional port. + ports: + - "127.0.0.1:5433:5432" networks: - caskeleton-local healthcheck: diff --git a/docs/httpclient/repository-adaptation.md b/docs/httpclient/repository-adaptation.md index 4cd9064..baa15fe 100644 --- a/docs/httpclient/repository-adaptation.md +++ b/docs/httpclient/repository-adaptation.md @@ -27,7 +27,7 @@ Therefore the design's 19 library modules become **package boundaries inside the | Design module | Repository home | Reason | |---|---|---| | `httpclient-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`) | This repository's composition root owns wiring and canonical activation; an adapter leaf must not auto-configure itself. | -| `httpclient-testkit` | `:adapter:outbound:httpclient` `src/test/java/**/testkit` | The design forbids production modules depending on the testkit; a test source set gives the same guarantee without a new Gradle project. | +| `httpclient-testkit` | `:adapter:outbound:httpclient` `src/testkit/java/**/testkit` | The design forbids production modules depending on the testkit; a source set whose dependencies are declared only on the test configurations gives the same guarantee without a new Gradle project. It is its own source set rather than part of `test` because three lanes consume it — `test`, `httpClientPerformanceTest` and `jmh` — and reaching into `sourceSets.test.output` from `jmh` compiled under Gradle but could not be modelled by an IDE, which classifies a source set as test source only when a `Test` task runs its output and forbids main source from reading test source. `PlatformClasses` excludes the source set's output so the boundary rules keep meaning production classes. | The package boundary is enforced by ArchUnit rules (`PublicApiArchitectureTest`, `HttpClientModuleBoundaryTest`) that reproduce the design's module dependency table. @@ -56,7 +56,7 @@ Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbo | `httpclient-spring7-service-groups` | `…httpclient.spring7` | `…outbound.httpclient.spring7` | | `httpclient-jetty-http3-experimental` | `…httpclient.http3` | `…outbound.httpclient.http3` | | `httpclient-spring-boot-starter` | `…httpclient.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.httpclient` | -| `httpclient-testkit` | `…httpclient.testkit` | `…outbound.httpclient.testkit` (test source set) | +| `httpclient-testkit` | `…httpclient.testkit` | `…outbound.httpclient.testkit` (`testkit` source set) | ## 3. Other deliberate substitutions diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index 17978fd..11fe5ec 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -2698,7 +2698,11 @@ env_keys: allowed_values: null classification: sensitive-config required: false - required_when: app.redis.mode=sentinel + # Applied only when present: RedisTopologyClientFactory sets the sentinel credentials provider + # through ifPresent, so a Sentinel deployment whose sentinels accept unauthenticated discovery + # starts without it. The unconditional "app.redis.mode=sentinel" this used to declare was a + # requirement the runtime never enforced. + required_when: app.redis.mode=sentinel and the sentinels require authentication reload_policy: restart-only owner_branch: redis-optionality-and-composition validation: nonblank_when_required @@ -2724,11 +2728,13 @@ env_keys: property: app.redis.sentinel.nodes owner_module: adapter-outbound-cache-redis type: csv - default: null + default: app.redis.nodes allowed_values: null classification: public-config required: false - required_when: app.redis.mode=sentinel + # Falls back to app.redis.nodes by design, so a deployment that points nodes at its sentinels + # and says nothing else is the common case rather than a misconfiguration. + required_when: app.redis.mode=sentinel and app.redis.nodes does not list the sentinels reload_policy: restart-only owner_branch: redis-optionality-and-composition validation: csv_nonempty @@ -2743,7 +2749,9 @@ env_keys: allowed_values: null classification: public-config required: false - required_when: app.redis.tls.enabled=true + # A key manager is configured only when this is present. Ordinary one-way TLS needs no client + # certificate, so requiring one whenever TLS is on was a claim the runtime never made. + required_when: app.redis.tls.enabled=true and the server requires mutual TLS reload_policy: restart-only owner_branch: redis-optionality-and-composition validation: nonblank_when_required @@ -2758,7 +2766,10 @@ env_keys: allowed_values: null classification: sensitive-config required: false - required_when: app.redis.tls.enabled=true + # A relationship between two settings rather than a switch: a client certificate without its + # key cannot build a key manager. Enforced in RedisSdkSettings.validate and covered by + # RedisSdkSettingsTest, which is where the registry's prose conditions are proven. + required_when: app.redis.tls.client-certificate-resource is configured reload_policy: restart-only owner_branch: redis-optionality-and-composition validation: nonblank_when_required @@ -2804,7 +2815,9 @@ env_keys: allowed_values: null classification: public-config required: false - required_when: app.redis.tls.enabled=true + # A trust manager is installed only when this is present; otherwise the JDK default trust + # anchors apply, which is enough for a server certificate from a public CA. + required_when: app.redis.tls.enabled=true and the server certificate is not publicly trusted reload_policy: restart-only owner_branch: redis-optionality-and-composition validation: nonblank_when_required diff --git a/infra/redis-sdk/acl/sentinel-accounts.acl b/infra/redis-sdk/acl/sentinel-accounts.acl new file mode 100644 index 0000000..2e0ed04 --- /dev/null +++ b/infra/redis-sdk/acl/sentinel-accounts.acl @@ -0,0 +1,4 @@ +user default off +user ca-skeleton-sentinel-client on >fixture-sentinel-client ~* &* -@all +auth +hello +ping +client|setname +client|id +subscribe +psubscribe +unsubscribe +punsubscribe +info +sentinel|get-master-addr-by-name +sentinel|master +sentinel|masters +sentinel|replicas +sentinel|slaves +sentinel|sentinels +sentinel|is-master-down-by-addr +user ca-skeleton-sentinel-peer on >fixture-sentinel-peer ~* &* +@all +user ca-skeleton-sentinel-operator on >fixture-sentinel-operator ~* &* -@all +auth +hello +ping +client|setname +info +subscribe +psubscribe +sentinel|get-master-addr-by-name +sentinel|master +sentinel|masters +sentinel|replicas +sentinel|slaves +sentinel|sentinels +sentinel|failover +sentinel|reset diff --git a/src/README.md b/src/README.md index 482dfda..d229319 100644 --- a/src/README.md +++ b/src/README.md @@ -431,13 +431,51 @@ Boot 기본값이 바뀌어도 wire 계약이 조용히 깨지지 않게 합니 - **`APP_SECURITY_CORS_ALLOW_CREDENTIALS`** — `true` | `false`. - **`APP_SECURITY_CORS_MAX_AGE`** — preflight 캐시 TTL(초). +### 프로파일과 데이터베이스 + +프로파일마다 데이터스토어가 다르고, 그 차이는 `app-bootstrap/src/main/resources/application-*.yml` +가 소유합니다. + +| 프로파일 | 데이터스토어 | 스키마 소유자 | 외부 인프라 | +| --- | --- | --- | --- | +| `local` (기본) | H2 in-memory | Hibernate `ddl-auto: create-drop` | 없음 | +| `dev` | PostgreSQL | Flyway `db/migration/postgresql` | 필요 | +| `prod` | PostgreSQL | Flyway `db/migration/postgresql` | 필요 | + +`local` 은 `./gradlew :app-bootstrap:bootRun` 의 기본값(`src/.env` 의 `SPRING_PROFILES_ACTIVE=local`) +이라 Docker 없이 바로 뜹니다. 아래 `APP_DATASOURCE_*` 값은 `local` 에서는 쓰이지 않고 +`application-local.yml` 이 덮어씁니다. + +`local` 이 검증하는 것은 wiring·요청/응답·애플리케이션 로직이고, **검증하지 않는 것은 migration 과 +vendor 동작**입니다. H2 에는 migration tree 가 없어 migration 에만 존재하는 테이블(capability schema +registry, polling-delivery·inbox stream, Spring Integration lock)이 만들어지지 않습니다. 해당 +capability 는 `local` 기본값에서 꺼져 있고, 켜면 테이블 없음으로 실패합니다. + +`dev` 를 호스트에서 띄우려면(`SPRING_PROFILES_ACTIVE=dev`) PostgreSQL 이 필요합니다. +`docker-compose.local.yml` 의 `db` 서비스가 루프백(`127.0.0.1:5433`)에만 게시하며, 이 주소가 +아래 `APP_DATASOURCE_URL` 의 커밋된 기본값입니다. 저장소 루트에서 실행합니다. + +```bash +docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --wait db +``` + +컨테이너로 띄우는 `./gradlew bootstrap` 경로는 이 호스트 포트를 쓰지 않습니다. compose 가 app +컨테이너의 `APP_DATASOURCE_URL` 을 내부 네트워크 주소 `jdbc:postgresql://db:5432/...` 로 덮어씁니다. + +`ca-skeleton.persistence.vendor`(`postgresql` | `h2`)가 어느 vendor 구성을 조립할지 고르는 단일 +스위치입니다. 값이 둘 중 하나가 아니면 기동이 실패하고, prod 에서 `h2` 이거나 datasource URL 이 +`jdbc:h2:` 이면 `PersistenceVendorProdSafetyValidator` 가 기동을 거부합니다(env 로 덮어써도 동일). + ### Database (Postgres) -- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`). +- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`). `dev`·`prod` 에서 + 쓰이며, 커밋된 기본값 `jdbc:postgresql://localhost:5433/ca_skeleton` 은 위 compose `db` 서비스의 + 호스트 주소입니다. - **`APP_DATASOURCE_USERNAME`** / **`APP_DATASOURCE_PASSWORD`** — DB 접속 계정. - **`APP_DATASOURCE_DRIVER`** — Hibernate dialect 에 맞는 드라이버(예: `org.postgresql.Driver`). - **`APP_DATASOURCE_DDL_AUTO`** — `none` | `validate` | `update` | `create` | `create-drop`. **prod - 는 `validate` 또는 `none`**, local 은 `update` 가 편리합니다. + 는 `validate` 또는 `none`**(`JpaSchemaSafetyValidator` 가 기동 시 강제). `local` 은 이 값을 쓰지 + 않습니다 — `application-local.yml` 이 `create-drop` 으로 고정합니다. - **`APP_DATASOURCE_SHOW_SQL`** — `true` 면 SQL 을 로그로 echo. - **`APP_DATASOURCE_FORMAT_SQL`** — SQL pretty-print(`SHOW_SQL=true` 일 때만 의미 있음). - **`APP_DATASOURCE_OPEN_IN_VIEW`** — Hibernate OSIV. **prod 에서는 피하세요.** diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java index fcf8058..2f577f0 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java @@ -82,8 +82,20 @@ public class RedisSdkSettings { if (mode == RedisDeploymentMode.SENTINEL && (sentinel.getMasterName() == null || sentinel.getMasterName().isBlank())) { throw new IllegalStateException( - "a Sentinel deployment must name the monitored primary; without it the client cannot" - + " resolve a primary at all, let alone follow a promotion"); + "app.redis.sentinel.master-name is required when app.redis.mode=sentinel: a Sentinel" + + " deployment must name the monitored primary; without it the client cannot resolve" + + " a primary at all, let alone follow a promotion"); + } + // A certificate without its key cannot build a key manager. Refused here rather than at the + // first connection, where it surfaced as a NullPointerException from inside the SSL options. + if (tls.isEnabled() + && tls.getClientCertificateResource() != null + && !tls.getClientCertificateResource().isBlank() + && (tls.getClientKeyReference() == null || tls.getClientKeyReference().isBlank())) { + throw new IllegalStateException( + "app.redis.tls.client-key-reference is required when" + + " app.redis.tls.client-certificate-resource is configured: mutual TLS presents a" + + " certificate, and a certificate without its private key cannot be presented"); } if (tls.isEnabled() && !tls.isHostnameVerification()) { warnings.add( @@ -99,11 +111,15 @@ public class RedisSdkSettings { } if (raw.isEnabled() && (raw.getCredentialReference() == null || raw.getCredentialReference().isBlank())) { - throw new IllegalStateException("the raw gateway requires its own credential reference"); + throw new IllegalStateException( + "app.redis.raw.credential-reference is required when app.redis.raw.enabled=true: the raw" + + " gateway authenticates as its own account"); } if (admin.isEnabled() && (admin.getCredentialReference() == null || admin.getCredentialReference().isBlank())) { - throw new IllegalStateException("the admin plane requires its own credential reference"); + throw new IllegalStateException( + "app.redis.admin.credential-reference is required when app.redis.admin.enabled=true: the" + + " admin plane authenticates as its own account"); } if (!advanced.isEnabled() && !advanced.getPolicies().isEmpty()) { throw new IllegalStateException( diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfigurationTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfigurationTest.java index 304bce4..6e97086 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfigurationTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfigurationTest.java @@ -441,7 +441,7 @@ class RedisSdkAutoConfigurationTest { context -> { assertThat(context).hasFailed(); assertThat(context.getStartupFailure()) - .hasStackTraceContaining("the admin plane requires its own credential reference"); + .hasStackTraceContaining("app.redis.admin.credential-reference"); }); } } diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettingsTest.java index 55c7978..6b576bb 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettingsTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettingsTest.java @@ -71,7 +71,10 @@ class RedisSdkSettingsTest { RedisSdkSettings missingCredential = validProperties(); missingCredential.getRaw().setEnabled(true); - assertThatThrownBy(missingCredential::validate).hasMessageContaining("credential reference"); + // The setting by name. An operator reading "requires its own credential reference" has to work + // out which of the five credential references the registry declares is the missing one. + assertThatThrownBy(missingCredential::validate) + .hasMessageContaining("app.redis.raw.credential-reference"); } @Test @@ -79,7 +82,37 @@ class RedisSdkSettingsTest { RedisSdkSettings properties = validProperties(); properties.getAdmin().setEnabled(true); - assertThatThrownBy(properties::validate).hasMessageContaining("credential reference"); + assertThatThrownBy(properties::validate) + .hasMessageContaining("app.redis.admin.credential-reference"); + } + + @Test + void aClientCertificateWithoutItsKeyIsRefusedAtStartup() { + // The registry declares this one as a relationship between two settings rather than a switch, + // so RequiredWhenIsEnforcedTest skips it and this is where the claim is kept. Before it was + // checked here, the missing key surfaced as a NullPointerException while the SSL options were + // being built — at the first connection, not at startup. + RedisSdkSettings properties = validProperties(); + properties.getTls().setEnabled(true); + properties.getTls().setClientCertificateResource("classpath:redis/client.crt"); + + assertThatThrownBy(properties::validate) + .hasMessageContaining("app.redis.tls.client-key-reference"); + + properties.getTls().setClientKeyReference("secret://environment/APP_REDIS_TLS_CLIENT_KEY"); + + assertThatCode(properties::validate).doesNotThrowAnyException(); + } + + @Test + void oneWayTlsNeedsNoClientCertificateAndNoTrustMaterial() { + // What the registry used to claim was required whenever TLS was on. A server certificate from + // a public CA verifies against the JDK trust anchors, and a server that does not ask for a + // client certificate is not given one, so neither setting is a startup requirement. + RedisSdkSettings properties = validProperties(); + properties.getTls().setEnabled(true); + + assertThatCode(properties::validate).doesNotThrowAnyException(); } @Test diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RequiredWhenIsEnforcedTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RequiredWhenIsEnforcedTest.java new file mode 100644 index 0000000..ebb0ed7 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RequiredWhenIsEnforcedTest.java @@ -0,0 +1,152 @@ +package dev.caskeleton.adapter.outbound.cache.redis.sdk.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.yaml.snakeyaml.Yaml; + +/** + * Makes the registry's {@code required_when} keep its promise. + * + *

{@code verifyEnvKeys} checks that every bindable property has a row and every row names a + * property, which leaves the most load-bearing field on the row unchecked: {@code required_when} + * declares that a setting must be present once some condition holds. Nothing verified + * that, so several rows claimed a requirement the runtime did not enforce — a deployment could + * satisfy the documentation and still start with the setting missing, which is worse than an + * undocumented setting because it reads as covered. + * + *

Each machine-readable condition — {@code =} — becomes a case: enable the + * condition, omit the property, and require the context to fail. Prose conditions ("… is + * configured") are deliberately excluded and are exactly the rows whose rule is a relationship + * between two settings rather than a switch; {@code RedisSdkSettingsTest} covers those. + * + *

A row that cannot pass this test has two honest fixes and one dishonest one. Enforce the + * requirement, or weaken the declaration to what is true. Deleting the case is the third. + */ +class RequiredWhenIsEnforcedTest { + + private static final Path REGISTRY = + Path.of("..", "..", "..", "..", "docs", "registries", "env-keys.yaml"); + + /** Conditions this test can drive: a property, an equals sign, and a literal. */ + private record Condition(String property, String value) { + + static Optional parse(String declared) { + if (declared == null || !declared.contains("=") || declared.contains(" ")) { + return Optional.empty(); + } + int equals = declared.indexOf('='); + return Optional.of( + new Condition( + declared.substring(0, equals).strip(), declared.substring(equals + 1).strip())); + } + } + + @TestFactory + @DisplayName("every declared required_when condition is refused at startup when unmet") + List everyRequiredWhenIsEnforced() throws IOException { + List cases = new ArrayList<>(); + for (Map row : rows()) { + Object property = row.get("property"); + Object declared = row.get("required_when"); + if (!(property instanceof String bound) || !bound.startsWith("app.redis.")) { + continue; + } + Optional condition = + Condition.parse(declared instanceof String text ? text : null); + if (condition.isEmpty() || "app.redis.enabled".equals(condition.get().property())) { + // `app.redis.enabled=true` scopes a setting to Redis being on; it does not claim the + // setting must be present. Those rows are the SDK's defaults and have them. + continue; + } + cases.add( + DynamicTest.dynamicTest( + row.get("name") + " is required when " + declared, + () -> assertRefused(bound, condition.get(), String.valueOf(row.get("name"))))); + } + assertThat(cases) + .as("the registry declares conditional requirements; a run with none is a parse failure") + .isNotEmpty(); + return cases; + } + + private void assertRefused(String property, Condition condition, String envName) { + List properties = new ArrayList<>(); + properties.add("app.redis.enabled=true"); + properties.add("app.redis.nodes=redis-a:6379"); + properties.add( + "app.redis.authentication.credential-reference=secret://u@environment/APP_REDIS_PASSWORD"); + properties.add(condition.property() + "=" + condition.value()); + // Everything the condition itself needs in order to be reachable, minus the property under + // test — otherwise an unrelated earlier rule would fail the context and this case would pass + // for the wrong reason. + prerequisites(condition).forEach((key, value) -> properties.add(key + "=" + value)); + properties.removeIf(entry -> entry.startsWith(property + "=")); + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RedisSdkAutoConfiguration.class)) + .withBean( + RedisSdkAutoConfiguration.RedisSecretSource.class, + () -> name -> Optional.of("resolved-" + name)) + .withPropertyValues(properties.toArray(String[]::new)) + .run( + context -> { + assertThat(context) + .as( + "%s declares it is required when %s=%s, so a context without it must not" + + " start", + envName, condition.property(), condition.value()) + .hasFailed(); + assertThat(context.getStartupFailure()) + .as("the failure must name the setting, not something downstream of it") + .hasStackTraceContaining(shortName(property)); + }); + } + + /** The other settings a condition needs before the property under test can be the cause. */ + private static Map prerequisites(Condition condition) { + Map extra = new LinkedHashMap<>(); + if ("app.redis.mode".equals(condition.property()) + && "sentinel".equals(condition.value().toLowerCase(Locale.ROOT))) { + extra.put("app.redis.sentinel.master-name", "skeleton"); + extra.put( + "app.redis.sentinel.credential-reference", "secret://s@environment/APP_REDIS_SENTINEL"); + } + if ("app.redis.raw.enabled".equals(condition.property())) { + extra.put("app.redis.raw.credential-reference", "secret://r@environment/APP_REDIS_RAW"); + extra.put("app.redis.raw.policy-resource", "classpath:redis-sdk/redis-command-policy.yml"); + } + if ("app.redis.admin.enabled".equals(condition.property())) { + extra.put("app.redis.admin.credential-reference", "secret://a@environment/APP_REDIS_ADMIN"); + } + return extra; + } + + /** The last segment of the property, which is what a failure message can be expected to name. */ + private static String shortName(String property) { + int dot = property.lastIndexOf('.'); + return dot < 0 ? property : property.substring(dot + 1); + } + + @SuppressWarnings("unchecked") + private static List> rows() throws IOException { + try (InputStream registry = Files.newInputStream(REGISTRY)) { + Map parsed = new Yaml().load(registry); + return (List>) parsed.get("env_keys"); + } + } +} diff --git a/src/adapter/outbound/httpclient/CLAUDE.md b/src/adapter/outbound/httpclient/CLAUDE.md index d5fd72c..7175f8d 100644 --- a/src/adapter/outbound/httpclient/CLAUDE.md +++ b/src/adapter/outbound/httpclient/CLAUDE.md @@ -37,7 +37,10 @@ moving a type between packages. - `profile` depends publicly only on `api`. - transport packages never reach back into the gateways. - `resilience` never depends on a transport — retry eligibility is transport-neutral. -- no production package depends on `testkit`. +- no production package depends on `testkit`. The testkit lives in its own `testkit` source set + (`src/testkit/java`), consumed by the `test`, `httpClientPerformanceTest` and `jmh` lanes; its + dependencies are declared only on the test configurations, so production still cannot reach it. + `PlatformClasses` is the single definition of "production classes" the boundary rules import. - Stable code never references `http3`. - `org.springframework.web.service.registry` appears only in `spring7`. - `RestTemplate` appears only in `migration`. diff --git a/src/adapter/outbound/httpclient/build.gradle b/src/adapter/outbound/httpclient/build.gradle index 0cdf709..9d9d0c5 100644 --- a/src/adapter/outbound/httpclient/build.gradle +++ b/src/adapter/outbound/httpclient/build.gradle @@ -50,7 +50,8 @@ dependencies { // Testkit dependencies (design §28.1 test topology). They are test-scoped so no production - // module can depend on the testkit. + // module can depend on the testkit; the testkit source set inherits them by extending + // testImplementation, and the test lanes use them directly. testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' testImplementation 'com.squareup.okhttp3:okhttp-tls:4.12.0' testImplementation 'org.testcontainers:testcontainers' @@ -61,34 +62,69 @@ dependencies { testImplementation 'io.projectreactor.tools:blockhound:1.0.17.RELEASE' } -// Performance certification and JMH benchmarks are separate source sets: they are slow, they assert -// on resource bounds rather than behaviour, and they must never be part of the default unit lane. +// The testkit is its own source set, not part of `test`, because three lanes consume it and only +// one of them is a test lane. Reaching into `sourceSets.test.output` from `jmh` compiled under +// Gradle and could not be modelled by the IDE at all: a source set is test source there only when +// a Test task runs its output, `jmh` is driven by JavaExec, and IDE main source may not read IDE +// test source — so every testkit reference in the benchmarks was an unresolved type in the editor +// while the build was green. A source set nobody runs tests from is main source for all three +// consumers, which is what it always was. +// +// Performance certification and JMH benchmarks are separate source sets for their own reason: they +// are slow, they assert on resource bounds rather than behaviour, and they must never be part of +// the default unit lane. sourceSets { + testkit { + java.srcDir 'src/testkit/java' + compileClasspath += sourceSets.main.output + runtimeClasspath += output + compileClasspath + } httpClientPerformanceTest { java.srcDir 'src/httpClientPerformanceTest/java' - compileClasspath += sourceSets.main.output + sourceSets.test.output + compileClasspath += sourceSets.main.output + sourceSets.testkit.output runtimeClasspath += output + compileClasspath } jmh { java.srcDir 'src/jmh/java' - compileClasspath += sourceSets.main.output + sourceSets.test.output + compileClasspath += sourceSets.main.output + sourceSets.testkit.output runtimeClasspath += output + compileClasspath } } configurations { + // The testkit compiles against exactly what a test does: testImplementation already extends + // implementation, so this is the module's own dependencies plus the test libraries. + testkitImplementation.extendsFrom testImplementation + testkitRuntimeOnly.extendsFrom testRuntimeOnly httpClientPerformanceTestImplementation.extendsFrom testImplementation httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly jmhImplementation.extendsFrom testImplementation jmhRuntimeOnly.extendsFrom testRuntimeOnly } +// Every test lane compiles and runs against the testkit. +sourceSets.test { + compileClasspath += sourceSets.testkit.output + runtimeClasspath += sourceSets.testkit.output +} + dependencies { + // Testkit dependencies (design §28.1 test topology). They stay off the production + // configurations, so no production module can depend on the testkit. + testkitImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + testkitImplementation 'com.squareup.okhttp3:okhttp-tls:4.12.0' + testkitImplementation 'org.testcontainers:testcontainers' + testkitImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testkitImplementation 'org.testcontainers:testcontainers-toxiproxy' + testkitImplementation 'io.projectreactor:reactor-test' + testkitImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + testkitImplementation 'io.projectreactor.tools:blockhound:1.0.17.RELEASE' + jmhImplementation 'org.openjdk.jmh:jmh-core:1.37' jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37' } -tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } +// UTF-8 is pinned for every JavaCompile task in the root build; this leaf no longer repeats it. // JMH generates its harness classes at compile time. They are not our source, so the // compile-time checker and -Werror are switched off for that source set only; applying them @@ -103,7 +139,10 @@ tasks.named('spotbugsJmh') { enabled = false } -Closure applyContractSelection = { Test task -> +// Takes a Test task. The parameter is left untyped because the IDE's Gradle parser has no Gradle +// API on its classpath and reports the annotation as an unresolved type; Groovy dispatches the +// calls below dynamically either way. +Closure applyContractSelection = { task -> // Cross-transport contract lane. The same semantic contract runs against every Stable transport; // the transport under test is selected explicitly so a missing transport is an error, not a skip. task.systemProperty 'httpclient.contract.transports', diff --git a/src/adapter/outbound/httpclient/gradle.lockfile b/src/adapter/outbound/httpclient/gradle.lockfile index 0aeac8a..d8e5979 100644 --- a/src/adapter/outbound/httpclient/gradle.lockfile +++ b/src/adapter/outbound/httpclient/gradle.lockfile @@ -1,251 +1,251 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath +ch.qos.logback:logback-classic:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs -com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,spotbugs,testCompileClasspath -com.google.code.gson:gson:2.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.41.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,spotbugs,testCompileClasspath,testkitCompileClasspath +com.google.code.gson:gson:2.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:content-type:2.3=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:lang-tag:1.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.nimbusds:content-type:2.3=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.nimbusds:lang-tag:1.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.squareup.okhttp3:mockwebserver:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.squareup.okhttp3:okhttp-tls:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.squareup.okhttp3:okhttp:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.squareup.okio:okio-jvm:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.squareup.okio:okio:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-api:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-engine:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:mockwebserver:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.squareup.okhttp3:okhttp-tls:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.squareup.okio:okio:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-api:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle -commons-codec:commons-codec:1.19.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-codec:commons-codec:1.19.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle -commons-io:commons-io:2.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -io.github.resilience4j:resilience4j-bulkhead:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-ratelimiter:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-timelimiter:2.2.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-base:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-classes-quic:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-http2:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-http3:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-http:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-native-quic:4.2.17.Final=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-socks:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-handler-proxy:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-handler:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns-native-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-transport-classes-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-transport-native-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-transport:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor.netty:reactor-netty-core:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor.netty:reactor-netty-http:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor.tools:blockhound:1.0.17.RELEASE=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-test:3.8.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +io.github.resilience4j:resilience4j-bulkhead:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.github.resilience4j:resilience4j-ratelimiter:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.github.resilience4j:resilience4j-timelimiter:2.2.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-core:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-buffer:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-codec-base:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-codec-classes-quic:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-codec-http2:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-codec-http3:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-codec-http:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-codec-native-quic:4.2.17.Final=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +io.netty:netty-codec-socks:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-handler-proxy:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-handler:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-resolver-dns-native-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-resolver:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-transport-native-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.netty:netty-transport:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor.netty:reactor-netty-core:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor.netty:reactor-netty-http:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor.tools:blockhound:1.0.17.RELEASE=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor:reactor-test:3.8.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -junit:junit:4.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy-agent:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.18.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +junit:junit:4.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.java.dev.jna:jna:5.18.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.minidev:accessors-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.minidev:json-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath net.sf.jopt-simple:jopt-simple:5.0.4=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-compress:1.28.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.commons:commons-lang3:3.20.0=checkstyle,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-compress:1.28.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.commons:commons-math3:3.6.1=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.httpcomponents.client5:httpclient5:5.5.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.httpcomponents.core5:httpcore5:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents.client5:httpclient5:5.5.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=compileClasspath,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath -org.assertj:assertj-core:3.27.6=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=compileClasspath,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.assertj:assertj-core:3.27.6=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.awaitility:awaitility:4.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.eclipse.jetty.compression:jetty-compression-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty.compression:jetty-compression-gzip:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty.http3:jetty-http3-client-transport:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty.http3:jetty-http3-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty.http3:jetty-http3-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty.http3:jetty-http3-qpack:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty.quic:jetty-quic-api:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty.quic:jetty-quic-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty.quic:jetty-quic-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty.quic:jetty-quic-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-alpn-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-http:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-io:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hamcrest:hamcrest-core:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hamcrest:hamcrest:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hdrhistogram:HdrHistogram:2.2.2=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-gzip:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-client-transport:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-qpack:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-api:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-alpn-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-http:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-io:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.hamcrest:hamcrest-core:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.hamcrest:hamcrest:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jetbrains:annotations:17.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,httpClientPerformanceTestAnnotationProcessor,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jetbrains:annotations:17.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,httpClientPerformanceTestAnnotationProcessor,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit:junit-bom:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.latencyutils:LatencyUtils:2.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.mockito:mockito-core:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,mockitoAgent,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath +org.latencyutils:LatencyUtils:2.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.mockito:mockito-core:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,mockitoAgent,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.objenesis:objenesis:3.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.openjdk.jmh:jmh-core:1.37=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath org.openjdk.jmh:jmh-generator-annprocess:1.37=jmhAnnotationProcessor -org.opentest4j:opentest4j:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath +org.opentest4j:opentest4j:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.osgi:org.osgi.resource:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -org.reactivestreams:reactive-streams:1.0.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.7.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle -org.rnorth.duct-tape:duct-tape:1.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.skyscreamer:jsonassert:1.5.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webflux:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-toxiproxy:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-web:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-test:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-webflux:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-toxiproxy:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.yaml:snakeyaml:2.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath empty= diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/HttpClientModuleBoundaryTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/HttpClientModuleBoundaryTest.java index 39474d6..d276cc6 100644 --- a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/HttpClientModuleBoundaryTest.java +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/HttpClientModuleBoundaryTest.java @@ -1,8 +1,6 @@ package dev.caskeleton.adapter.outbound.httpclient.architecture; import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.lang.syntax.ArchRuleDefinition; import org.junit.jupiter.api.Test; @@ -15,10 +13,7 @@ import org.junit.jupiter.api.Test; */ class HttpClientModuleBoundaryTest { - private static final JavaClasses PLATFORM = - new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("dev.caskeleton.adapter.outbound.httpclient"); + private static final JavaClasses PLATFORM = PlatformClasses.production(); @Test void coreApiDependsOnNothingInsideThePlatform() { diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PlatformClasses.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PlatformClasses.java new file mode 100644 index 0000000..76e10b2 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PlatformClasses.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.outbound.httpclient.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; + +/** + * The platform's production classes, as every boundary rule in this module sees them. + * + *

Shared rather than repeated because "which classes are production" is one decision, and three + * rule classes silently disagreeing about it is how a boundary stops being enforced. + * + *

ArchUnit's {@code DO_NOT_INCLUDE_TESTS} recognises the conventional test output locations, and + * the testkit is no longer in one: it is its own source set, so it compiles to {@code + * build/classes/java/testkit}. Left in, the fixtures would be imported as production code and every + * rule here would be asserted against them — starting with the one that says production code never + * depends on the testkit, which the testkit itself trivially does. + */ +public final class PlatformClasses { + + private static final ImportOption NOT_THE_TESTKIT_SOURCE_SET = + location -> !location.contains("/classes/java/testkit/"); + + private static final JavaClasses PRODUCTION = + new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(NOT_THE_TESTKIT_SOURCE_SET) + .importPackages("dev.caskeleton.adapter.outbound.httpclient"); + + private PlatformClasses() {} + + /** + * Returns the platform's production classes. + * + * @return every compiled class of this module that is neither a test nor a testkit fixture + */ + public static JavaClasses production() { + return PRODUCTION; + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PublicApiArchitectureTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PublicApiArchitectureTest.java index b6659ec..2aa907b 100644 --- a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PublicApiArchitectureTest.java +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PublicApiArchitectureTest.java @@ -1,8 +1,6 @@ package dev.caskeleton.adapter.outbound.httpclient.architecture; import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.lang.syntax.ArchRuleDefinition; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -16,10 +14,7 @@ import org.junit.jupiter.api.Test; @Tag("httpclient-spring62-surface") class PublicApiArchitectureTest { - private static final JavaClasses PLATFORM = - new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("dev.caskeleton.adapter.outbound.httpclient"); + private static final JavaClasses PLATFORM = PlatformClasses.production(); @Test void publicApiDoesNotExposeNativeEnginesOrUnsafeBuilders() { diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/migration/RestTemplateBoundaryTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/migration/RestTemplateBoundaryTest.java index 6410d30..ac0708d 100644 --- a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/migration/RestTemplateBoundaryTest.java +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/migration/RestTemplateBoundaryTest.java @@ -1,10 +1,9 @@ package dev.caskeleton.adapter.outbound.httpclient.migration; import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.syntax.ArchRuleDefinition; +import dev.caskeleton.adapter.outbound.httpclient.architecture.PlatformClasses; import org.junit.jupiter.api.Test; /** @@ -15,10 +14,7 @@ import org.junit.jupiter.api.Test; */ class RestTemplateBoundaryTest { - private static final JavaClasses PLATFORM_CLASSES = - new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("dev.caskeleton.adapter.outbound.httpclient"); + private static final JavaClasses PLATFORM_CLASSES = PlatformClasses.production(); static ArchRule restTemplateIsConfinedToMigration() { return ArchRuleDefinition.noClasses() diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/BlockingTransportContract.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/BlockingTransportContract.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/BlockingTransportContract.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/BlockingTransportContract.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ClientProfiles.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ClientProfiles.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ClientProfiles.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ClientProfiles.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/CoreFixtures.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/CoreFixtures.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/CoreFixtures.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/CoreFixtures.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargetSecurityContract.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargetSecurityContract.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargetSecurityContract.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargetSecurityContract.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargets.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargets.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargets.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargets.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/EventPayload.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/EventPayload.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/EventPayload.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/EventPayload.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/Http2FailureFixture.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/Http2FailureFixture.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/Http2FailureFixture.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/Http2FailureFixture.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/HttpClientContract.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/HttpClientContract.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/HttpClientContract.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/HttpClientContract.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/InvalidPostClient.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/InvalidPostClient.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/InvalidPostClient.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/InvalidPostClient.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MissingKeyClient.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MissingKeyClient.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MissingKeyClient.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MissingKeyClient.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MixedSignatureClient.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MixedSignatureClient.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MixedSignatureClient.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MixedSignatureClient.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MockHttpServer.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MockHttpServer.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MockHttpServer.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MockHttpServer.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/NettyLeakDetectionExtension.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/NettyLeakDetectionExtension.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/NettyLeakDetectionExtension.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/NettyLeakDetectionExtension.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/NoopLifecycleListener.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/NoopLifecycleListener.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/NoopLifecycleListener.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/NoopLifecycleListener.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/OAuth2Fixture.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/OAuth2Fixture.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/OAuth2Fixture.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/OAuth2Fixture.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ObservabilityContract.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ObservabilityContract.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ObservabilityContract.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ObservabilityContract.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ProxyFixture.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ProxyFixture.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ProxyFixture.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ProxyFixture.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveEventsClient.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveEventsClient.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveEventsClient.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveEventsClient.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveTestGateways.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveTestGateways.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveTestGateways.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveTestGateways.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveTransportContract.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveTransportContract.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveTransportContract.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ReactiveTransportContract.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordedHttpRequest.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordedHttpRequest.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordedHttpRequest.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordedHttpRequest.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingCredentialProvider.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingCredentialProvider.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingCredentialProvider.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingCredentialProvider.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingResilienceComponents.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingResilienceComponents.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingResilienceComponents.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingResilienceComponents.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingSleeper.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingSleeper.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingSleeper.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RecordingSleeper.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ResourceLifecycleContract.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ResourceLifecycleContract.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ResourceLifecycleContract.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ResourceLifecycleContract.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetryContexts.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetryContexts.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetryContexts.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetryContexts.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetrySafetyContract.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetrySafetyContract.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetrySafetyContract.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetrySafetyContract.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/StatefulUpstream.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/StatefulUpstream.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/StatefulUpstream.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/StatefulUpstream.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TestGateways.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TestGateways.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TestGateways.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TestGateways.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TlsFixture.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TlsFixture.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TlsFixture.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TlsFixture.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TlsMaterials.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TlsMaterials.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TlsMaterials.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/TlsMaterials.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ToxiproxyFixture.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ToxiproxyFixture.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ToxiproxyFixture.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ToxiproxyFixture.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UnsafeRetryClient.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UnsafeRetryClient.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UnsafeRetryClient.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UnsafeRetryClient.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UserResponse.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UserResponse.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UserResponse.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UserResponse.java diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UsersClient.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UsersClient.java similarity index 100% rename from src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UsersClient.java rename to src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/UsersClient.java diff --git a/src/adapter/outbound/persistence-jpa/CLAUDE.md b/src/adapter/outbound/persistence-jpa/CLAUDE.md index 79733a1..6da5ad6 100644 --- a/src/adapter/outbound/persistence-jpa/CLAUDE.md +++ b/src/adapter/outbound/persistence-jpa/CLAUDE.md @@ -30,7 +30,37 @@ adapters implement application/domain ports directly and must not depend on this `audit/DomainContextAuditContextPort`) — see "Persistence auditing contract" below. - Vendor SPI extension points shared by all RDBMS vendors: - `outbox/OutboxClaimRepository` — vendor module implements claim strategy (e.g. FOR UPDATE SKIP LOCKED). + - `idempotency/IdempotencyClaimRepository` — vendor implements insert-or-expired-reclaim. - `failure/SqlStateErrorMapping` — vendor module contributes vendor-specific SQLState rows. + - `transaction/TransactionLocalTimeoutConfigurer` — vendor applies statement/lock guards. + +## Vendor selection + +Two vendor compositions live in this module, each in its own subpackage, each registering the same +four SPI beans: + +| Vendor | Package | Selected by | Schema owner | +| --- | --- | --- | --- | +| PostgreSQL | `.postgresql` | `ca-skeleton.persistence.vendor=postgresql` (also the default) | Flyway, `db/migration/postgresql` | +| H2 | `.h2` | `ca-skeleton.persistence.vendor=h2` | Hibernate `ddl-auto`, entities only | + +`config/PersistenceVendorSettings` binds the selector to an enum, so an unknown value fails at +startup instead of loading neither composition and surfacing as a missing `OutboxClaimRepository`. +The profiles state the choice: `application-local.yml` selects H2, `application-dev.yml` and +`application-prod.yml` select PostgreSQL, and `PersistenceVendorProdSafetyValidator` (app-bootstrap) +refuses H2 under prod whatever property source supplies it. + +H2 is the local-development datastore, not a second production target. It has no migration tree, so +tables that exist only in migrations — capability schema registry, polling-delivery and inbox +streams, the Spring Integration lock table — do not exist under it. Vendor concurrency and migration +fidelity stay with `postgresqlIntegrationTest`. + +Two H2 statements diverge from PostgreSQL and the reasons are measured, not assumed (H2 2.4.240): + +- the outbox claim is identical — H2 accepts `FOR UPDATE SKIP LOCKED` and genuinely skips locked + rows, so the claim keeps its meaning; +- the idempotency claim is not — H2 has no `INSERT ... ON CONFLICT ... RETURNING`, so it is a + `MERGE ... USING` with the same three outcomes. `H2ClaimSqlTest` executes both against a real H2. ### Capability-gated stores @@ -59,11 +89,12 @@ should not exist. - Repository adapters owning `@Transactional` boundaries — the application use case owns the transaction via `TransactionPort` (see [application-core/CLAUDE.md](../../../application-core/CLAUDE.md)). -- **DB drivers** (`org.postgresql..`) or **`org.flywaydb.database.postgresql..`** — those are - vendor-specific and belong only in this module's `.postgresql` package; NoSQL-specific dependencies - belong only in their own future modules +- **DB drivers** (`org.postgresql..`, `org.h2..`) or **`org.flywaydb.database.postgresql..`** — + those are vendor-specific and belong only in this module's matching vendor package (`.postgresql`, + `.h2`); NoSQL-specific dependencies belong only in their own future modules (persistence-multi-db-extensibility D3). This is enforced by ArchUnit - `persistence_rdbms_stays_vendor_neutral` in `CleanArchitectureTest`. + `PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL` and `PERSISTENCE_RDBMS_STAYS_NEUTRAL_OF_H2` in + `CleanArchitectureTest`. - NoSQL adapter code. MongoDB/Redis/DynamoDB adapters are sibling modules, not children of this module. - Any sibling persistence or inbound/outbound adapter not allowed by the registry. @@ -128,6 +159,8 @@ framework-neutral `shared.error.PersistenceFailureException` carrying one of the | `40P01` | `DB_DEADLOCK` | PostgreSQL (`.postgresql` package) | | `25P03` | `DB_IDLE_IN_TX_TIMEOUT` | PostgreSQL | | `57014` | `DB_QUERY_CANCELED` | PostgreSQL | +| `23513` | `DB_CHECK_VIOLATION` | H2 (`.h2` package) — H2 reports CHECK as 23513, not the standard 23514 the core table maps | +| `HYT00` | `DB_QUERY_CANCELED` | H2 — H2 collapses statement and lock timeout into one state | - A repository adapter that catches a `DataAccessException` calls `translator.translate(ex)` and rethrows the carrier (`ifPresent(e -> { throw e; })`); diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index 09098e8..7bbb230 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -35,6 +35,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-flyway' runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.flywaydb:flyway-database-postgresql' + + // Vendor (H2): the local-profile driver. Used only by the .h2 subpackage, which reaches it + // through JDBC/JPA rather than by importing org.h2 types — the same shape as the PostgreSQL + // driver above. Not `developmentOnly`: local is a deployable profile of this artifact, and the + // vendor selector, not the packaging, decides which driver a deployment loads. + runtimeOnly 'com.h2database:h2' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-postgresql' diff --git a/src/adapter/outbound/persistence-jpa/gradle.lockfile b/src/adapter/outbound/persistence-jpa/gradle.lockfile index 5b7c859..0e04cce 100644 --- a/src/adapter/outbound/persistence-jpa/gradle.lockfile +++ b/src/adapter/outbound/persistence-jpa/gradle.lockfile @@ -37,6 +37,7 @@ com.google.guava:guava:33.6.0-jre=checkstyle com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor +com.h2database:h2:2.4.240=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.jayway.jsonpath:json-path:2.9.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java new file mode 100644 index 0000000..eba19cb --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Selects which RDBMS vendor composition this deployment runs. + * + *

The selector is a property rather than a profile name because the vendor is a property of the + * datastore, not of the environment that happens to use it. A fork that runs PostgreSQL under a + * profile named something other than {@code dev}/{@code prod}, or that wants H2 in a throwaway + * demo, sets this key; it does not have to rename its profiles or edit a condition. + * + *

Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the + * two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first + * missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming + * {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that + * caused it. + */ +@ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX) +public record PersistenceVendorSettings(Vendor vendor) { + + public static final String PREFIX = "ca-skeleton.persistence"; + public static final String VENDOR_PROPERTY = PREFIX + ".vendor"; + + /** The RDBMS vendors this repository composes a persistence adapter for. */ + public enum Vendor { + POSTGRESQL, + H2 + } + + public PersistenceVendorSettings { + // Absent means PostgreSQL: the vendor every deployment before this selector existed ran, so an + // upgrade that does not set the key keeps its datastore. + vendor = vendor == null ? Vendor.POSTGRESQL : vendor; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java new file mode 100644 index 0000000..bcfa66c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.outbound.persistence.h2; + +import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.Nullable; + +/** + * H2 atomic scope claim. + * + *

H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL + * statement does not port. The standard {@code MERGE ... USING} does, and carries the same + * meaning in one statement: + * + *

+ * + *

One statement rather than select-then-insert is what keeps the SPI's promise not to poison the + * caller transaction: a losing claim returns zero updated rows, never a constraint violation the + * surrounding transaction would have to absorb. + * + *

No {@code RETURNING} is needed. The PostgreSQL statement returns {@code EXCLUDED.id}, which is + * the proposed id on both branches, so a claimed row is always this caller's proposed id. + */ +public final class H2IdempotencyClaimRepository implements IdempotencyClaimRepository { + + private static final String CLAIM_SQL = + """ + MERGE INTO idempotency_record t + USING (VALUES ( + CAST(:id AS uuid), CAST(:tenant AS varchar(128)), CAST(:principal AS varchar(256)), + CAST(:idempotencyKey AS varchar(256)), CAST(:useCaseName AS varchar(256)), + CAST(:requestHash AS varchar(64)), + CAST(:createdAt AS timestamp(6) with time zone), + CAST(:expiresAt AS timestamp(6) with time zone) + )) AS s (id, tenant, principal, idempotency_key, use_case_name, + request_hash, created_at, expires_at) + ON t.tenant = s.tenant + AND t.principal = s.principal + AND t.idempotency_key = s.idempotency_key + AND t.use_case_name = s.use_case_name + WHEN MATCHED AND t.expires_at <= :now THEN UPDATE SET + id = s.id, + request_hash = s.request_hash, + status = 'IN_FLIGHT', + response_payload = NULL, + response_ref = NULL, + created_at = s.created_at, + expires_at = s.expires_at + WHEN NOT MATCHED THEN INSERT ( + id, tenant, principal, idempotency_key, use_case_name, + request_hash, status, response_payload, response_ref, created_at, expires_at + ) VALUES ( + s.id, s.tenant, s.principal, s.idempotency_key, s.use_case_name, + s.request_hash, 'IN_FLIGHT', NULL, NULL, s.created_at, s.expires_at + ) + """; + + private final EntityManager entityManager; + + public H2IdempotencyClaimRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Optional tryClaim( + IdempotencyRecordEntity proposed, + Instant now, + @Nullable IdempotencyRecordEntity exactExpiredEntity) { + // Same detach as the PostgreSQL path: a managed copy of the row this statement is about to + // overwrite would be flushed back over the claim at commit. + if (exactExpiredEntity != null && entityManager.contains(exactExpiredEntity)) { + entityManager.detach(exactExpiredEntity); + } + int claimed = + entityManager + .createNativeQuery(CLAIM_SQL) + .setParameter("id", proposed.getId()) + .setParameter("tenant", proposed.getTenant()) + .setParameter("principal", proposed.getPrincipal()) + .setParameter("idempotencyKey", proposed.getIdempotencyKey()) + .setParameter("useCaseName", proposed.getUseCaseName()) + .setParameter("requestHash", proposed.getRequestHash()) + .setParameter("createdAt", proposed.getCreatedAt()) + .setParameter("expiresAt", proposed.getExpiresAt()) + .setParameter("now", now) + .executeUpdate(); + return claimed == 1 ? Optional.of(proposed.getId()) : Optional.empty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java new file mode 100644 index 0000000..ef7fd5c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.persistence.h2; + +import dev.caskeleton.adapter.outbound.persistence.transaction.EffectiveTransactionTimeouts; +import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer; +import java.time.Duration; +import java.util.Objects; +import org.springframework.jdbc.core.JdbcOperations; + +/** + * Applies H2's timeout guards to the connection bound to the current transaction. + * + *

Two differences from the PostgreSQL configurer, both inherent to H2 rather than choices: + * + *

+ * + *

The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They + * arrive as {@link Duration}s from validated settings, never from request input, and a negative one + * is rejected below rather than concatenated. + */ +public final class H2LocalTimeoutConfigurer implements TransactionLocalTimeoutConfigurer { + + private static final String STATEMENT_TIMEOUT_SQL = "SET QUERY_TIMEOUT "; + private static final String LOCK_TIMEOUT_SQL = "SET LOCK_TIMEOUT "; + + private final JdbcOperations jdbcOperations; + + public H2LocalTimeoutConfigurer(JdbcOperations jdbcOperations) { + this.jdbcOperations = Objects.requireNonNull(jdbcOperations, "jdbcOperations must be non-null"); + } + + @Override + public void apply(EffectiveTransactionTimeouts timeouts) { + Objects.requireNonNull(timeouts, "timeouts must be non-null"); + apply(STATEMENT_TIMEOUT_SQL, "statementTimeout", timeouts.statementTimeout()); + apply(LOCK_TIMEOUT_SQL, "lockTimeout", timeouts.lockTimeout()); + } + + private void apply(String command, String name, Duration timeout) { + long milliseconds = timeout.toMillis(); + if (milliseconds < 0) { + throw new IllegalArgumentException(name + " must not be negative, but was " + timeout); + } + jdbcOperations.execute(command + milliseconds); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2OutboxClaimRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2OutboxClaimRepository.java new file mode 100644 index 0000000..3f43da6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2OutboxClaimRepository.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.persistence.h2; + +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.util.List; + +/** + * H2 {@link OutboxClaimRepository}. + * + *

The statement is character-for-character the PostgreSQL one, because H2 2.4 accepts {@code FOR + * UPDATE SKIP LOCKED} and honours it: a probe holding a row lock on one connection saw a concurrent + * {@code SKIP LOCKED} claim return zero rows rather than block or read through the lock. The claim + * therefore keeps its meaning here — competing relay workers take disjoint rows — instead of + * degrading to a serialised scan. + * + *

Kept as its own class rather than shared with the PostgreSQL implementation: the SPI exists so + * a vendor can diverge, and the packages are the boundary ArchUnit enforces. A shared "portable + * SQL" base would make the next H2-only fix a change to PostgreSQL's claim path. + */ +public final class H2OutboxClaimRepository implements OutboxClaimRepository { + + private static final String CLAIM_SQL = + """ + SELECT * FROM outbox_event o + WHERE o.next_attempt_at <= :now + AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT') + AND NOT EXISTS ( + SELECT 1 FROM outbox_event p + WHERE p.aggregate_id = o.aggregate_id + AND p.occurred_at < o.occurred_at + AND p.status <> 'PUBLISHED' + ) + ORDER BY o.occurred_at ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """; + + private final EntityManager entityManager; + + public H2OutboxClaimRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @SuppressWarnings("unchecked") + public List claimEligible(Instant now, int limit) { + return entityManager + .createNativeQuery(CLAIM_SQL, OutboxEventEntity.class) + .setParameter("now", now) + .setParameter("limit", limit) + .getResultList(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java new file mode 100644 index 0000000..2c1c70a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.persistence.h2; + +import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig; +import dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings; +import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer; +import jakarta.persistence.EntityManager; +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.jdbc.core.JdbcOperations; + +/** + * H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers, + * implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the + * {@code local} profile sets. + * + *

No Flyway location customizer, deliberately. The PostgreSQL vendor points Flyway at + * {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local + * profile turns Flyway off and lets Hibernate derive the schema from the entities. Two + * consequences worth stating out loud: + * + *

+ * + *

Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency + * fidelity stay with the real-PostgreSQL integration suites. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = PersistenceVendorSettings.PREFIX, + name = "vendor", + havingValue = "h2") +@Import(PersistenceJpaConfig.class) +public class H2PersistenceConfig { + + @Bean + public OutboxClaimRepository outboxClaimRepository(EntityManager entityManager) { + return new H2OutboxClaimRepository(entityManager); + } + + @Bean + public SqlStateErrorMapping h2SqlStateErrorMapping() { + return new H2SqlStateErrorMapping(); + } + + @Bean + public TransactionLocalTimeoutConfigurer transactionLocalTimeoutConfigurer( + JdbcOperations jdbcOperations) { + return new H2LocalTimeoutConfigurer(jdbcOperations); + } + + @Bean + public IdempotencyClaimRepository idempotencyClaimRepository(EntityManager entityManager) { + return new H2IdempotencyClaimRepository(entityManager); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2SqlStateErrorMapping.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2SqlStateErrorMapping.java new file mode 100644 index 0000000..8057b12 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2SqlStateErrorMapping.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.persistence.h2; + +import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; +import dev.caskeleton.shared.error.OperationalError; +import java.util.Map; + +/** + * H2-specific {@link SqlStateErrorMapping} rows. + * + *

H2 emits the standard SQLStates for unique ({@code 23505}) and not-null ({@code 23502}) + * violations, which the vendor-neutral matrix already covers. Two states it does not share are + * below; both were read off a running H2 2.4.240 rather than inferred from the standard. + * + * + * + * + * + * + * + * + * + * + * + * + *
H2 vendor rows
SQLStatecodewhy
{@code 23513}{@code DB_CHECK_VIOLATION}H2 reports a failed CHECK constraint as 23513, not the 23514 the neutral matrix maps. + * Without this row a check violation falls through as an unmapped INTERNAL.
{@code HYT00}{@code DB_QUERY_CANCELED}H2 collapses every timeout-guard expiry into one state. PostgreSQL splits the same + * ground across 57014 (statement) and 55P03 (lock) and this repository maps only 57014, + * so DB_QUERY_CANCELED is the existing code for "a guard stopped the statement".
+ */ +public final class H2SqlStateErrorMapping implements SqlStateErrorMapping { + + private static final Map MAPPINGS = + Map.of( + "23513", OperationalError.DB_CHECK_VIOLATION, + "HYT00", OperationalError.DB_QUERY_CANCELED); + + @Override + public Map exactMappings() { + return MAPPINGS; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java index 323d94b..b43cab0 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java @@ -1,11 +1,13 @@ package dev.caskeleton.adapter.outbound.persistence.postgresql; import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig; +import dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings; import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository; import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer; import jakarta.persistence.EntityManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,8 +17,17 @@ import org.springframework.jdbc.core.JdbcOperations; /** * PostgreSQL vendor persistence configuration: imports the core JPA config and registers the vendor * {@code @Bean}s. See the module README. + * + *

{@code matchIfMissing = true} keeps PostgreSQL the default: this configuration was + * unconditional before {@link PersistenceVendorSettings} existed, and a deployment that never sets + * the selector must keep the vendor it already runs. */ @Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = PersistenceVendorSettings.PREFIX, + name = "vendor", + havingValue = "postgresql", + matchIfMissing = true) @Import(PersistenceJpaConfig.class) public class PostgreSqlPersistenceConfig { diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java index 4db8dd4..72d310a 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java @@ -32,9 +32,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcOperations; -import org.springframework.stereotype.Repository; import org.springframework.transaction.support.TransactionSynchronizationManager; /** @@ -43,8 +41,19 @@ import org.springframework.transaction.support.TransactionSynchronizationManager *

Mutations require an application-owned primary read-write transaction. The row is locked * before {@code clock_timestamp()} is evaluated, and every state change repeats the complete owner * CAS tuple in SQL. Raw client idempotency keys never reach this adapter. + * + *

Deliberately carries no Spring stereotype. Both composition roots component-scan {@code + * dev.caskeleton.adapter}, so a {@code @Repository} here was registered in every deployment + * regardless of which idempotency provider was selected: {@code provider=jdbc} acquired an + * owner-safe V2 store it never asked for, and {@code provider=redis} acquired a second one beside + * its own. Both counts are what {@code IdempotencyProviderSelectionConfig} refuses, so a scan- + * registered store meant neither selection could start. + * + *

{@code ca-skeleton.capabilities.idempotency.provider} is {@code disabled | jdbc | redis} and + * has no value that selects this store, so nothing composes it today; the integration test + * constructs it directly. Giving it a selector is outstanding work, and it belongs with the + * registry entry for that property rather than with a stereotype that composes it everywhere. */ -@Repository public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePortV2 { static final int INLINE_RESPONSE_MAX_BYTES = 8 * 1024; @@ -258,7 +267,6 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort private final JdbcOperations jdbc; private final SecureRandom secureRandom; - @Autowired public PostgreSqlOwnerSafeIdempotencyStore(JdbcOperations jdbc) { this(jdbc, new SecureRandom()); } diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSelectionTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSelectionTest.java new file mode 100644 index 0000000..dd04cd0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSelectionTest.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.outbound.persistence.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings.Vendor; +import dev.caskeleton.adapter.outbound.persistence.h2.H2PersistenceConfig; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlPersistenceConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +/** + * Guards the vendor selector itself: the value binding, and the two conditions that turn a value + * into a composition. + * + *

The conditions are asserted on their declared metadata rather than by loading the two + * configurations, because both import the JPA entity/repository registration and would drag a live + * {@code EntityManagerFactory} into a test about a string. What the loaded composition then does + * against a real database is {@code H2ClaimSqlTest}'s job. + */ +class PersistenceVendorSelectionTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(VendorSettings.class); + + @ParameterizedTest + @ValueSource(strings = {"postgresql", "POSTGRESQL", "h2", "H2"}) + void bindsTheSupportedVendorsCaseInsensitively(String value) { + runner + .withPropertyValues(PersistenceVendorSettings.VENDOR_PROPERTY + "=" + value) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(PersistenceVendorSettings.class).vendor()) + .isEqualTo(Vendor.valueOf(value.toUpperCase(java.util.Locale.ROOT))); + }); + } + + @Test + void defaultsToPostgreSqlWhenTheSelectorIsAbsent() { + runner.run( + context -> + assertThat(context.getBean(PersistenceVendorSettings.class).vendor()) + .isEqualTo(Vendor.POSTGRESQL)); + } + + @Test + void rejectsAnUnknownVendorAtStartupRatherThanComposingNothing() { + runner + .withPropertyValues(PersistenceVendorSettings.VENDOR_PROPERTY + "=mysql") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining(PersistenceVendorSettings.VENDOR_PROPERTY); + }); + } + + @Test + void postgreSqlIsTheCompositionAKeylessDeploymentGets() { + ConditionalOnProperty condition = + PostgreSqlPersistenceConfig.class.getAnnotation(ConditionalOnProperty.class); + + assertThat(condition).isNotNull(); + assertThat(condition.prefix()).isEqualTo(PersistenceVendorSettings.PREFIX); + assertThat(condition.name()).containsExactly("vendor"); + assertThat(condition.havingValue()).isEqualTo("postgresql"); + assertThat(condition.matchIfMissing()) + .as("an upgrade that never sets the selector must keep the vendor it already runs") + .isTrue(); + } + + @Test + void h2IsOnlyEverSelectedExplicitly() { + ConditionalOnProperty condition = + H2PersistenceConfig.class.getAnnotation(ConditionalOnProperty.class); + + assertThat(condition).isNotNull(); + assertThat(condition.prefix()).isEqualTo(PersistenceVendorSettings.PREFIX); + assertThat(condition.name()).containsExactly("vendor"); + assertThat(condition.havingValue()).isEqualTo("h2"); + assertThat(condition.matchIfMissing()) + .as("an in-memory datastore must never be what a deployment gets by saying nothing") + .isFalse(); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(PersistenceVendorSettings.class) + static class VendorSettings {} +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java new file mode 100644 index 0000000..a18dd28 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java @@ -0,0 +1,226 @@ +package dev.caskeleton.adapter.outbound.persistence.h2; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity; +import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import java.util.UUID; +import java.util.function.Function; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.orm.jpa.EntityManagerFactoryUtils; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Runs the H2 vendor claim statements against a real H2, because their risk is dialect acceptance + * rather than branch logic. The idempotency claim in particular is not a translation of the + * PostgreSQL statement — H2 has no {@code ON CONFLICT ... RETURNING}, so it is a {@code MERGE ... + * USING}, and only an execution proves that the substitution kept the three outcomes intact. + * + *

In-memory and process-local, so this stays an ordinary unit test: no container, no network, + * nothing to skip when Docker is absent. Real-PostgreSQL fidelity remains the job of the + * {@code postgresqlIntegrationTest} source set. + */ +class H2ClaimSqlTest { + + private static final String TENANT = "tenant-a"; + private static final String PRINCIPAL = "principal-a"; + private static final String USE_CASE = "PlaceOrder"; + + private static LocalContainerEntityManagerFactoryBean factoryBean; + private static EntityManagerFactory entityManagerFactory; + private static TransactionTemplate transactionTemplate; + + private Instant now; + + @BeforeAll + static void startDatabase() { + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName("org.h2.Driver"); + // Same URL shape as application-local.yml, so the test exercises the dialect and identifier + // folding the local profile actually runs. + dataSource.setUrl( + "jdbc:h2:mem:h2claimsql;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + dataSource.setUsername("sa"); + dataSource.setPassword(""); + + HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); + vendorAdapter.setGenerateDdl(true); + + Properties jpaProperties = new Properties(); + jpaProperties.setProperty("hibernate.hbm2ddl.auto", "create-drop"); + + factoryBean = new LocalContainerEntityManagerFactoryBean(); + factoryBean.setDataSource(dataSource); + factoryBean.setPersistenceUnitName("h2-claim-sql"); + factoryBean.setPackagesToScan( + "dev.caskeleton.adapter.outbound.persistence.idempotency.entity", + "dev.caskeleton.adapter.outbound.persistence.outbox.entity"); + factoryBean.setJpaVendorAdapter(vendorAdapter); + factoryBean.setJpaProperties(jpaProperties); + factoryBean.afterPropertiesSet(); + entityManagerFactory = factoryBean.getObject(); + + JpaTransactionManager transactionManager = new JpaTransactionManager(entityManagerFactory); + transactionManager.afterPropertiesSet(); + transactionTemplate = new TransactionTemplate(transactionManager); + } + + @AfterAll + static void stopDatabase() { + factoryBean.destroy(); + } + + @BeforeEach + void clearTables() { + now = Instant.parse("2026-08-12T00:00:00Z"); + inTransaction( + entityManager -> { + entityManager.createQuery("DELETE FROM IdempotencyRecordEntity").executeUpdate(); + entityManager.createQuery("DELETE FROM OutboxEventEntity").executeUpdate(); + return null; + }); + } + + @Test + void claimsAFreeScope() { + UUID proposed = UUID.randomUUID(); + + Optional claimed = tryClaim(proposed, "key-1", now.plus(Duration.ofHours(1)), now); + + assertThat(claimed).contains(proposed); + assertThat(storedStatus("key-1")).isEqualTo("IN_FLIGHT"); + } + + @Test + void losesToALiveClaimOnTheSameScope() { + UUID winner = UUID.randomUUID(); + tryClaim(winner, "key-2", now.plus(Duration.ofHours(1)), now); + + Optional second = + tryClaim(UUID.randomUUID(), "key-2", now.plus(Duration.ofHours(1)), now); + + assertThat(second).as("a live winner must not be displaced").isEmpty(); + assertThat(storedId("key-2")).isEqualTo(winner); + } + + @Test + void takesOverAnExpiredClaimOnTheSameScope() { + UUID abandoned = UUID.randomUUID(); + Instant expiry = now.plus(Duration.ofHours(1)); + tryClaim(abandoned, "key-3", expiry, now); + + UUID reclaimer = UUID.randomUUID(); + Optional retaken = tryClaim(reclaimer, "key-3", expiry.plus(Duration.ofHours(2)), expiry); + + assertThat(retaken).contains(reclaimer); + assertThat(storedId("key-3")).isEqualTo(reclaimer); + } + + @Test + void claimsOnlyTheOldestUnpublishedEventPerAggregate() { + inTransaction( + entityManager -> { + entityManager.persist(event("evt-old", "agg-1", now.minusSeconds(60), now)); + entityManager.persist(event("evt-new", "agg-1", now.minusSeconds(30), now)); + entityManager.persist(event("evt-other", "agg-2", now.minusSeconds(10), now)); + return null; + }); + + List claimed = claimEligible(now, 10); + + assertThat(claimed).extracting(OutboxEventEntity::getEventId).containsExactly("evt-old", + "evt-other"); + } + + @Test + void skipsEventsWhoseNextAttemptIsInTheFuture() { + inTransaction( + entityManager -> { + entityManager.persist( + event("evt-backoff", "agg-3", now.minusSeconds(60), now.plusSeconds(300))); + return null; + }); + + assertThat(claimEligible(now, 10)).isEmpty(); + } + + private Optional tryClaim(UUID id, String key, Instant expiresAt, Instant asOf) { + return inTransaction( + entityManager -> + new H2IdempotencyClaimRepository(entityManager) + .tryClaim( + new IdempotencyRecordEntity( + id, + TENANT, + PRINCIPAL, + key, + USE_CASE, + "request-hash", + "IN_FLIGHT", + null, + null, + asOf, + expiresAt), + asOf, + null)); + } + + private List claimEligible(Instant asOf, int limit) { + return inTransaction( + entityManager -> new H2OutboxClaimRepository(entityManager).claimEligible(asOf, limit)); + } + + private String storedStatus(String key) { + return inTransaction(entityManager -> stored(entityManager, key).getStatus()); + } + + private UUID storedId(String key) { + return inTransaction(entityManager -> stored(entityManager, key).getId()); + } + + private static IdempotencyRecordEntity stored(EntityManager entityManager, String key) { + return entityManager + .createQuery( + "SELECT r FROM IdempotencyRecordEntity r WHERE r.idempotencyKey = :key", + IdempotencyRecordEntity.class) + .setParameter("key", key) + .getSingleResult(); + } + + private static OutboxEventEntity event( + String eventId, String aggregateId, Instant occurredAt, Instant nextAttemptAt) { + OutboxEventEntity entity = new OutboxEventEntity(); + entity.setEventId(eventId); + entity.setAggregateId(aggregateId); + entity.setEventType("OrderPlaced"); + entity.setPayload("{}"); + entity.setOccurredAt(occurredAt); + entity.setStatus("PENDING"); + entity.setAttemptCount(0); + entity.setNextAttemptAt(nextAttemptAt); + entity.setCorrelationId("corr-1"); + entity.setIdempotencyKey("idem-" + eventId); + return entity; + } + + private T inTransaction(Function work) { + return transactionTemplate.execute( + status -> + work.apply( + EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory))); + } +} diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index 2b59940..6bd6f36 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -65,6 +65,7 @@ com.google.protobuf:protobuf-java:3.25.5=conditionalTransportTestRuntimeClasspat com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.graphql-java:graphql-java:25.0=conditionalTransportTestRuntimeClasspath com.graphql-java:java-dataloader:6.0.0=conditionalTransportTestRuntimeClasspath +com.h2database:h2:2.4.240=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSelectionConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSelectionConfig.java index 421f13c..1def0eb 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSelectionConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSelectionConfig.java @@ -2,6 +2,7 @@ package dev.caskeleton.bootstrap.idempotency; import dev.caskeleton.application.idempotency.IdempotencyExecutor; import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.v2.IdempotencyExecutorV2; import dev.caskeleton.application.idempotency.v2.IdempotencyStorePortV2; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.SmartInitializingSingleton; @@ -18,10 +19,13 @@ import org.springframework.context.annotation.Configuration; * therefore required a bean no provider could supply, and the deployment failed with "ambiguous or * incomplete" while every configured provider was present and correct. * - *

It also no longer requires a V2 executor. {@code IdempotencyExecutorV2} is written against - * that same unimplemented parent-package contract, so demanding one made every Redis deployment - * unstartable rather than proving anything. Driving the V2 store from an executor is real - * outstanding work; pretending the guard covers it is not the way to track it. + *

It requires a V2 executor alongside the V2 store. A store with nothing to drive it is a + * selection that cannot serve a request, and the executor is the only thing that turns the store's + * claim/start/complete transitions into a request-replay lifecycle. The requirement was dropped + * once, while the only {@code IdempotencyExecutorV2} was written against the unimplemented + * parent-package contract and so could never be composed; that executor now exists in {@code + * application.idempotency.v2} over the contract the stores implement, so the requirement is real + * again rather than a demand nothing could satisfy. */ @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(IdempotencyProviderSettings.class) @@ -32,17 +36,19 @@ public class IdempotencyProviderSelectionConfig { IdempotencyProviderSettings settings, ObjectProvider jdbcStores, ObjectProvider jdbcExecutors, - ObjectProvider ownerSafeStores) { + ObjectProvider ownerSafeStores, + ObjectProvider ownerSafeExecutors) { return () -> { - int jdbcStoreCount = count(jdbcStores); - int jdbcExecutorCount = count(jdbcExecutors); - int ownerSafeStoreCount = count(ownerSafeStores); + Counts found = + new Counts( + count(jdbcStores), + count(jdbcExecutors), + count(ownerSafeStores), + count(ownerSafeExecutors)); switch (settings.provider()) { - case DISABLED -> - requireCounts(jdbcStoreCount, jdbcExecutorCount, ownerSafeStoreCount, 0, 0, 0); - case JDBC -> requireCounts(jdbcStoreCount, jdbcExecutorCount, ownerSafeStoreCount, 1, 1, 0); - case REDIS -> - requireCounts(jdbcStoreCount, jdbcExecutorCount, ownerSafeStoreCount, 0, 0, 1); + case DISABLED -> require(found, new Counts(0, 0, 0, 0)); + case JDBC -> require(found, new Counts(1, 1, 0, 0)); + case REDIS -> require(found, new Counts(0, 0, 1, 1)); default -> throw new IllegalStateException( "Unsupported idempotency provider: " + settings.provider()); @@ -54,29 +60,29 @@ public class IdempotencyProviderSelectionConfig { return Math.toIntExact(beans.stream().count()); } - private static void requireCounts( - int jdbcStores, - int jdbcExecutors, - int ownerSafeStores, - int expectedJdbcStores, - int expectedJdbcExecutors, - int expectedOwnerSafeStores) { - if (jdbcStores != expectedJdbcStores - || jdbcExecutors != expectedJdbcExecutors - || ownerSafeStores != expectedOwnerSafeStores) { + private static void require(Counts found, Counts expected) { + if (!found.equals(expected)) { throw new IllegalStateException( "Idempotency provider selection is ambiguous or incomplete: expected " - + expectedJdbcStores - + " JDBC V1 store(s), " - + expectedJdbcExecutors - + " JDBC V1 executor(s) and " - + expectedOwnerSafeStores - + " owner-safe V2 store(s), but found " - + jdbcStores - + ", " - + jdbcExecutors - + " and " - + ownerSafeStores); + + expected.describe() + + ", but found " + + found.describe()); + } + } + + /** The four bean counts the selection is exact about, in one comparable value. */ + private record Counts( + int jdbcStores, int jdbcExecutors, int ownerSafeStores, int ownerSafeExecutors) { + + String describe() { + return jdbcStores + + " JDBC V1 store(s), " + + jdbcExecutors + + " JDBC V1 executor(s), " + + ownerSafeStores + + " owner-safe V2 store(s) and " + + ownerSafeExecutors + + " owner-safe V2 executor(s)"; } } } diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java index 67262a0..651b61f 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java @@ -13,6 +13,7 @@ import dev.caskeleton.adapter.outbound.cache.redis.sdk.config.RedisSdkAutoConfig import dev.caskeleton.adapter.outbound.cache.redis.sdk.config.RedisSdkSettings; import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner; import dev.caskeleton.application.cache.CacheRegionPort; +import dev.caskeleton.application.idempotency.v2.IdempotencyExecutorV2; import dev.caskeleton.application.idempotency.v2.IdempotencyStorePortV2; import dev.caskeleton.application.lease.DistributedLeasePort; import dev.caskeleton.bootstrap.runtime.SecretSource; @@ -270,6 +271,34 @@ public class RedisCapabilityConfig { idempotency.getCommandTimeout()); } + /** + * The request-replay lifecycle driven over the selected owner-safe store. + * + *

Provider-neutral by type — it depends on {@link IdempotencyStorePortV2}, not on Redis — and + * composed here because this is where the settings behind it are bound. A selection that produced + * the store and no executor produced a store nothing could drive, which is what {@code + * IdempotencyProviderSelectionConfig} refuses. + * + * @param store the selected owner-safe store + * @param capabilities the capability settings + * @return the owner-safe request-replay executor + */ + @Bean + @ConditionalOnProperty( + name = "ca-skeleton.capabilities.idempotency.provider", + havingValue = "redis") + IdempotencyExecutorV2 idempotencyExecutorV2( + IdempotencyStorePortV2 store, RedisCapabilitySettings capabilities) { + RedisCapabilitySettings.Idempotency idempotency = capabilities.getIdempotency(); + return new IdempotencyExecutorV2( + store, + idempotency.getProcessingLease(), + idempotency.getReplayTtl(), + idempotency.getFailureRetention(), + idempotency.getResponseCodecId(), + idempotency.getPolicyRevision()); + } + /** * Digests a semantic key before it reaches Redis. * diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java index 3841457..2de8d52 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java @@ -395,7 +395,12 @@ public class RedisCapabilitySettings { private Duration replayTtl = Duration.ofHours(24); private Duration failureRetention = Duration.ofHours(24); private String responseCodecId = "json-v2"; - private String policyRevision = "request-replay-v2"; + + // The owner-safe claim carries this as a number, not a name: the store writes it into the + // record and compares it there, and `IdempotencyClaimRequest` rejects anything below 1. It + // used to be the string "request-replay-v2", which nothing read, because the only executor + // that could have read it was written against a contract nothing implements. + private int policyRevision = 2; public boolean redisSelected() { return "redis".equalsIgnoreCase(provider); @@ -457,11 +462,11 @@ public class RedisCapabilitySettings { this.responseCodecId = responseCodecId; } - public String getPolicyRevision() { + public int getPolicyRevision() { return policyRevision; } - public void setPolicyRevision(String policyRevision) { + public void setPolicyRevision(int policyRevision) { this.policyRevision = policyRevision; } } diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/PersistenceVendorProdSafetyValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/PersistenceVendorProdSafetyValidator.java new file mode 100644 index 0000000..84b9c3c --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/PersistenceVendorProdSafetyValidator.java @@ -0,0 +1,89 @@ +package dev.caskeleton.bootstrap.runtime; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.util.Locale; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.Environment; + +/** + * Keeps the in-memory development datastore out of production. + * + *

{@code application-prod.yml} already pins {@code ca-skeleton.persistence.vendor=postgresql}, + * but a YAML default only wins against the property sources quieter than it: an environment + * variable or a container override outranks every committed file. The two ways prod could end up on + * H2 are therefore checked at startup, where the source of the value no longer matters. + * + *

Both checks are worth having separately, because either one alone leaves a hole: + * + *

+ * + *

An H2 URL also slips silently past {@link PostgreSqlTransportSecurityValidator}, which only + * inspects {@code jdbc:postgresql:} URLs: prod would lose its TLS requirement and its durability in + * the same move, and report neither. + * + *

Scoped to H2 by name rather than asserting "must be PostgreSQL", so a fork that adds a third + * production-grade vendor is not blocked by this validator. + */ +public final class PersistenceVendorProdSafetyValidator implements SmartInitializingSingleton { + + static final String VENDOR_KEY = "ca-skeleton.persistence.vendor"; + static final String JDBC_URL_KEY = "spring.datasource.url"; + static final String JDBC_URL_ENV_KEY = "APP_DATASOURCE_URL"; + + private static final String PROD_PROFILE = "prod"; + private static final String H2_VENDOR = "h2"; + private static final String H2_URL_PREFIX = "jdbc:h2:"; + + private final Environment environment; + + public PersistenceVendorProdSafetyValidator(Environment environment) { + this.environment = environment; + } + + @Override + public void afterSingletonsInstantiated() { + if (!isProdActive()) { + return; + } + + String vendor = normalized(environment.getProperty(VENDOR_KEY)); + if (H2_VENDOR.equals(vendor)) { + throw StartupFailures.profileMismatch( + "prod profile forbids " + + VENDOR_KEY + + "=h2: the H2 composition is the in-memory local datastore and loses every write" + + " when the process restarts"); + } + + String jdbcUrl = normalized(environment.getProperty(JDBC_URL_KEY)); + // The URL is never echoed back — it can carry credentials, endpoints and database names + // (same reason as PostgreSqlTransportSecurityValidator). + if (jdbcUrl != null && jdbcUrl.startsWith(H2_URL_PREFIX)) { + throw StartupFailures.profileMismatch( + "prod profile forbids an H2 datasource: " + + JDBC_URL_ENV_KEY + + " (" + + JDBC_URL_KEY + + ") resolves to a jdbc:h2: URL, which is the in-memory local datastore and also" + + " bypasses the prod PostgreSQL TLS check"); + } + } + + private static String normalized(String value) { + return value == null ? null : value.trim().toLowerCase(Locale.ROOT); + } + + private boolean isProdActive() { + for (String profile : environment.getActiveProfiles()) { + if (PROD_PROFILE.equalsIgnoreCase(profile)) { + return true; + } + } + return false; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java index 8bcb05c..426de3c 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java @@ -40,6 +40,12 @@ public class RuntimeSafetyConfig { return new PostgreSqlTransportSecurityValidator(environment); } + @Bean + PersistenceVendorProdSafetyValidator persistenceVendorProdSafetyValidator( + Environment environment) { + return new PersistenceVendorProdSafetyValidator(environment); + } + @Bean HikariPoolConstraintValidator hikariPoolConstraintValidator(Environment environment) { return new HikariPoolConstraintValidator(environment); diff --git a/src/app-bootstrap/src/main/resources/application-dev.yml b/src/app-bootstrap/src/main/resources/application-dev.yml new file mode 100644 index 0000000..9545a3b --- /dev/null +++ b/src/app-bootstrap/src/main/resources/application-dev.yml @@ -0,0 +1,22 @@ +# ============================================================================= +# dev profile — PostgreSQL, Flyway-migrated. +# +# Deliberately thin. The datasource, pool, and every other operational value stay env-driven in +# application.yml and src/.env, because a shared dev database is an operator-supplied address, not +# a value this file can know. What belongs here is the shape dev must have whatever the operator +# sets: the vendor and the schema owner. +# +# Both keys below restate the repository default rather than change it, so adding this file moves +# no behaviour. That is the point — the moment dev and prod diverge from local, the difference has +# a declared home instead of being implied by whatever the environment happened to inject. +# ============================================================================= + +spring: + flyway: + # Flyway owns the schema from dev onward. Stated rather than inherited so switching a profile + # to H2 cannot silently carry a migration expectation with it. + enabled: true + +ca-skeleton: + persistence: + vendor: postgresql diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml new file mode 100644 index 0000000..dfc54bf --- /dev/null +++ b/src/app-bootstrap/src/main/resources/application-local.yml @@ -0,0 +1,183 @@ +# ============================================================================= +# local profile — in-memory H2, no external infrastructure, no environment. +# +# This file is a complete standalone configuration, not a patch. application.yml resolves ~70 +# values from ${APP_*} placeholders that carry no inline default, so a launcher that does not +# inject src/.env dies during property binding — and because logback-spring.xml reads the same +# unresolved ca-skeleton.logging.* values, the logging system fails first and swallows the console +# output that would have said so. The observable symptom is a process that exits 1 having printed +# nothing. +# +# Only `./gradlew :app-bootstrap:bootRun` injects src/.env (see app-bootstrap/build.gradle). An IDE +# Run/Debug on CaSkeletonApplication, `java -jar`, and a bare container do not. Restating the +# required values here is what makes "local needs nothing" true for every launcher rather than for +# one Gradle task. +# +# The placeholders stay in application.yml untouched: dev and prod must keep failing loudly when an +# operator forgets a value. Local is the profile allowed to have answers of its own. +# ProfileSeparationContractTest#localProfileAnswersEveryRequiredPlaceholder fails the build when +# application.yml grows a required placeholder this file does not cover. +# +# What local verifies: wiring, request/response behaviour, application logic. +# What it does not: migrations and vendor behaviour — the schema here comes from the JPA entities, +# and the dev/prod path is the one Flyway proves. +# ============================================================================= + +spring: + application: + name: ca-skeleton + web: + error: + include-stacktrace: never + include-message: never + datasource: + # MODE=PostgreSQL keeps H2's dialect and semantics as close to the deployed vendor as H2 gets; + # DATABASE_TO_LOWER matches PostgreSQL's unquoted-identifier folding so the native SQL in the + # vendor adapters resolves the same table and column names in both. + # + # DB_CLOSE_DELAY=-1 is load-bearing, not decoration: an in-memory database is dropped when its + # last connection closes, and HikariCP closes idle connections. Without it the schema + # disappears mid-run the first time the pool goes idle. + url: jdbc:h2:mem:ca_skeleton;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1 + username: sa + password: "" + driver-class-name: org.h2.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + # Milliseconds. Must stay above the 3000 ms validation-timeout pinned in application.yml + # (HIKARI-CFG-C6), which HikariPoolConstraintValidator enforces at startup. + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + flyway: + # The migration tree is PostgreSQL DDL — DO $$ blocks and all — so there is nothing here for + # Flyway to apply. Off rather than pointed at an empty location: with Flyway on and no vendor + # location override it falls back to classpath:db/migration and walks the PostgreSQL tree. + enabled: false + jpa: + hibernate: + # Hibernate owns the local schema, which is only safe because the database is thrown away + # with the process. JpaSchemaSafetyValidator rejects this mode under prod. + ddl-auto: create-drop + show-sql: false + open-in-view: false + properties: + hibernate: + format_sql: false + security: + oauth2: + resourceserver: + jwt: + # The local Keycloak realm. Startup does not contact it — Spring defers JWKS resolution to + # first use — so an unauthenticated call such as GET /api/healthcheck works with no IdP + # running at all. Authenticated calls need it up. + issuer-uri: http://localhost:8081/realms/ca-skeleton + audiences: ca-skeleton-api + main: + banner-mode: console + lazy-initialization: false + log-startup-info: true + threads: + virtual: + enabled: true + jackson: + deserialization: + fail-on-unknown-properties: true + fail-on-null-for-primitives: true + fail-on-ignored-properties: true + datatype: + enum: + read-unknown-enum-values-as-null: false + datetime: + write-dates-as-timestamps: false + lifecycle: + timeout-per-shutdown-phase: 30s + +server: + port: 8080 + shutdown: graceful + # No proxy in front of a local run, so trusting X-Forwarded-* would let a caller forge its own + # client IP. dev/prod sit behind a load balancer and set this to framework via env. + forward-headers-strategy: none + tomcat: + threads: + max: 200 + min-spare: 10 + accept-count: 100 + max-connections: 8192 + connection-timeout: 20s + compression: + enabled: true + min-response-size: 1024 + +logging: + level: + root: INFO + dev.caskeleton: DEBUG + org.springframework: INFO + org.springframework.web: INFO + org.hibernate.SQL: WARN + +ca-skeleton: + persistence: + # Selects H2PersistenceConfig instead of PostgreSqlPersistenceConfig: the claim SQL, SQLState + # rows and timeout guards differ per vendor even though the ports do not. + vendor: h2 + bootstrap: + app-name: ca-skeleton + presentation: + # Pinned even though application.yml has an inline default, because the default is /v1 and + # src/.env says /api. Everything that names a URL in this repository — SECURITY_PUBLIC_PATHS, + # the compose healthchecks, bootstrapSmoke, the README curl — assumes /api, so a launcher that + # missed src/.env served /v1/healthcheck and answered 401 on the address the docs give. + api-base-path: /api + privacy: + # A blank salt makes PrivacySettings warn and fall back to a dev sentinel. Naming the dev value + # keeps the local pseudonymization stable across runs and keeps the warning for deployments + # that really did forget to supply one. + pseudonymization-salt: __LOCAL_DEV_pseudonymization_salt + idempotency: + ttl: 24h + security: + issuer-uri: http://localhost:8081/realms/ca-skeleton + audience: ca-skeleton-api + public-paths: /api/healthcheck + cors: + enabled: true + allowed-origins: http://localhost:3000 + # Empty = the default method set (GET, POST, PATCH, PUT, DELETE, OPTIONS). + allowed-methods: "" + allowed-headers: "*" + allow-credentials: true + max-age-seconds: 3600 + logging: + file: + # Console only. A local run should not quietly grow a logs/ directory in the working + # directory, which differs between a Gradle run and an IDE run. + enabled: false + path: logs/ca-skeleton.json + max-size: 100MB + max-history: 14 + total-size-cap: 3GB + async: + enabled: true + queue-size: 512 + discarding-threshold: 20 + json: + timezone: UTC + timestamp-pattern: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" + include-caller-data: false + logger-name-length: 0 + # Keep every log line locally; prod samples <=INFO down to 10%. + sampling-rate: 1.0 + +app: + messaging: + # Blank = messaging disabled; the broker binds a fail-fast sentinel rather than a real client. + broker: "" + notification: + slack: + provider: "" + email: + provider: "" diff --git a/src/app-bootstrap/src/main/resources/application-prod.yml b/src/app-bootstrap/src/main/resources/application-prod.yml new file mode 100644 index 0000000..0be1044 --- /dev/null +++ b/src/app-bootstrap/src/main/resources/application-prod.yml @@ -0,0 +1,28 @@ +# ============================================================================= +# prod profile — PostgreSQL, Flyway-migrated. +# +# As thin as the dev profile, and for the same reason: production values are operator-supplied and +# live in the environment, not in a committed file. Only the shape is pinned here. +# +# The prod safety rules are NOT restated in this file. They are startup validators, because a +# forbidden value has to fail whether it arrives from this file, an env var, or a container +# override — a YAML default only wins against the sources that are quieter than it: +# +# JpaSchemaSafetyValidator ddl-auto must be none|validate (exit 71) +# FlywayProdSafetyValidator baseline-on-migrate / out-of-order / clean stay disarmed +# StartupSafetyValidator error-detail exposure and body-capture logging stay off +# PostgreSqlTransportSecurityValidator pgJDBC sslmode=verify-full +# PersistenceVendorProdSafetyValidator neither the vendor selector nor the URL may be H2 +# +# The vendor pin below is therefore the readable statement of intent, not the enforcement: an env +# var outranks this file, so overriding it to h2 fails at startup rather than booting an in-memory +# database that loses every write on restart. +# ============================================================================= + +spring: + flyway: + enabled: true + +ca-skeleton: + persistence: + vendor: postgresql diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml index 6345544..9e080b0 100644 --- a/src/app-bootstrap/src/main/resources/application.yml +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -356,7 +356,8 @@ ca-skeleton: replay-ttl: ${APP_IDEMPOTENCY_TTL:24h} failure-retention: ${APP_IDEMPOTENCY_FAILURE_RETENTION:24h} response-codec-id: json-v2 - policy-revision: request-replay-v2 + # Numeric: the owner-safe claim writes this into the record and compares it there. + policy-revision: 2 lease: # disabled | redis. This is EFFICIENCY_ONLY and never supplies fencing. provider: ${APP_LEASE_PROVIDER:disabled} @@ -411,6 +412,18 @@ ca-skeleton: lock: wait-time: 3s lease-ttl: 30s + # Which RDBMS vendor composition the persistence adapter registers: postgresql | h2. Bound to + # PersistenceVendorSettings (adapter-persistence), which rejects any other value at startup. + # + # A property rather than a profile check, because the vendor belongs to the datastore and not to + # the environment that happens to use it. The profiles then state their choice: + # application-local.yml selects h2, application-dev.yml and application-prod.yml select + # postgresql, and prod additionally refuses h2 through PersistenceVendorProdSafetyValidator. + # + # The value here is the default for a deployment that activates no profile at all — PostgreSQL, + # the vendor every deployment ran before this selector existed. + persistence: + vendor: postgresql # JPA named-policy deadline envelope. JpaTransactionSettings validates the hierarchy; # SpringPolicyTransactionPort intersects these limits with the caller's absolute CallBudget # and the actual Hikari connection timeout before acquiring a transaction. diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java index 25012cd..0106c10 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java @@ -873,6 +873,25 @@ class CleanArchitectureTest { + ".postgresql subpackage of adapter:outbound:persistence-jpa.") .allowEmptyShould(true); + // The same fence around the second vendor. H2 arrives as a runtimeOnly driver, so today nothing + // imports org.h2.. and this rule holds vacuously — which is the point of writing it now: the + // first class that reaches for an H2 type has to do it inside the vendor package, before the + // "vendor-neutral base" claim quietly stops being true. + @ArchTest + static final ArchRule PERSISTENCE_RDBMS_STAYS_NEUTRAL_OF_H2 = + noClasses() + .that() + .resideInAPackage("dev.caskeleton.adapter.outbound.persistence..") + .and() + .resideOutsideOfPackage("dev.caskeleton.adapter.outbound.persistence.h2..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("org.h2..") + .as( + "adapter:outbound:persistence-jpa (non-h2) must stay vendor-neutral: H2 driver types " + + "live only in the .h2 subpackage of adapter:outbound:persistence-jpa.") + .allowEmptyShould(true); + @ArchTest static final ArchRule PERSISTENCE_RDBMS_ENTITIES_DO_NOT_PIN_VENDOR_COLUMN_DEFINITIONS = fields() diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java index 09119a0..b7e6be9 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java @@ -111,6 +111,32 @@ class DeveloperExperienceContractTest { .contains("http://localhost:8080/api/healthcheck"); } + /** + * src/.env is the host-side source bootRun reads, so its datasource URL names a host port. A + * database the compose stack keeps on its internal network only is unreachable from there, and + * every host-side run dies in the startup migration phase with a connection refusal. + */ + @Test + void localComposePublishesTheHostPortTheCommittedDatasourceUrlTargets() throws IOException { + String datasourceUrl = envValue("APP_DATASOURCE_URL"); + Matcher target = Pattern.compile("^jdbc:postgresql://([^:/]+):(\\d+)/").matcher(datasourceUrl); + assertThat(target.find()) + .as("APP_DATASOURCE_URL must name an explicit host and port: %s", datasourceUrl) + .isTrue(); + String host = target.group(1); + String hostPort = target.group(2); + + assertThat(host) + .as("the committed datasource default is host-side, so it must resolve to the loopback") + .isIn("localhost", "127.0.0.1"); + + Map local = parseYamlMap(read("docker-compose.local.yml")); + Map database = requireMap(requireMap(local, "services"), "db"); + assertThat(requireStringList(database, "ports")) + .as("a host-side run cannot reach a database the compose stack never publishes") + .contains("127.0.0.1:" + hostPort + ":5432"); + } + @Test void readmeCommandsAreVerifiedAndBootstrapIsTheFirstRunEntrypoint() throws IOException { String build = read("src/build.gradle"); @@ -1022,6 +1048,17 @@ class DeveloperExperienceContractTest { return Files.readString(REPOSITORY_ROOT.resolve(relative)); } + private static String envValue(String key) throws IOException { + String prefix = key + "="; + return read("src/.env") + .lines() + .map(String::trim) + .filter(line -> line.startsWith(prefix)) + .map(line -> line.substring(prefix.length()).trim()) + .findFirst() + .orElseThrow(() -> new AssertionError("src/.env does not declare " + key)); + } + private static Map parseYamlMap(String source) { LoaderOptions options = new LoaderOptions(); options.setAllowDuplicateKeys(false); diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java new file mode 100644 index 0000000..026e7f7 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java @@ -0,0 +1,282 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Pins the environment split: local runs on an in-memory database, every deployed profile runs on + * PostgreSQL with Flyway owning the schema. + * + *

These are file assertions rather than a booted context on purpose. Booting the real + * composition root inside this source set is not currently possible — the component scan that makes + * {@code CaSkeletonApplication} the composition root also finds the nested {@code @Configuration} + * classes that dozens of tests here declare, and they collide. The behaviour behind these files is + * covered where it can be: {@code H2ClaimSqlTest} runs the H2 statements against a real H2, + * {@code PersistenceVendorSelectionTest} covers the selector, and + * {@code PersistenceVendorProdSafetyValidatorTest} covers the prod refusal. What is left, and what + * this test guards, is the wiring between them drifting — a profile quietly changing vendor, or + * local regaining a migration expectation it cannot satisfy. + */ +class ProfileSeparationContractTest { + + private static final Path REPOSITORY_ROOT = repositoryRoot(); + private static final String VENDOR_KEY = "vendor"; + + @Test + void localRunsAnInMemoryDatabaseWithNoMigrations() throws IOException { + Map local = profile("local"); + + assertThat(vendorOf(local)).isEqualTo("h2"); + assertThat(datasourceUrlOf(local)) + .as("local must need no external database") + .startsWith("jdbc:h2:mem:"); + assertThat(flywayEnabledOf(local)) + .as("db/migration/postgresql is PostgreSQL DDL and cannot run on H2") + .isEqualTo(false); + } + + @Test + void localKeepsTheInMemoryDatabaseAliveAcrossPoolIdleness() throws IOException { + // An in-memory database is dropped when its last connection closes, and HikariCP closes idle + // connections: without this the schema disappears mid-run rather than at shutdown. + assertThat(datasourceUrlOf(profile("local"))).contains("DB_CLOSE_DELAY=-1"); + } + + @ParameterizedTest + @ValueSource(strings = {"dev", "prod"}) + void deployedProfilesRunPostgreSqlWithFlyway(String profile) throws IOException { + Map configuration = profile(profile); + + assertThat(vendorOf(configuration)).isEqualTo("postgresql"); + assertThat(flywayEnabledOf(configuration)).isEqualTo(true); + } + + @ParameterizedTest + @ValueSource(strings = {"dev", "prod"}) + void deployedProfilesLeaveTheDatasourceToTheEnvironment(String profile) throws IOException { + // A committed database address is either wrong or a leak; the ${APP_DATASOURCE_*} placeholders + // in application.yml stay the single entry point for it. + assertThat(datasourceUrlOf(profile(profile))).isNull(); + } + + @Test + void theProfilelessDefaultIsPostgreSqlRatherThanTheInMemoryDatabase() throws IOException { + Map base = yaml("application.yml"); + + assertThat(vendorOf(base)) + .as("a deployment that activates no profile must not silently get H2") + .isEqualTo("postgresql"); + } + + private static Map profile(String profile) throws IOException { + return yaml("application-" + profile + ".yml"); + } + + private static String vendorOf(Map configuration) { + Map persistence = child(child(configuration, "ca-skeleton"), "persistence"); + Object vendor = persistence == null ? null : persistence.get(VENDOR_KEY); + return vendor == null ? null : vendor.toString(); + } + + private static String datasourceUrlOf(Map configuration) { + Map datasource = child(child(configuration, "spring"), "datasource"); + Object url = datasource == null ? null : datasource.get("url"); + return url == null ? null : url.toString(); + } + + private static Boolean flywayEnabledOf(Map configuration) { + Map flyway = child(child(configuration, "spring"), "flyway"); + Object enabled = flyway == null ? null : flyway.get("enabled"); + return enabled instanceof Boolean value ? value : null; + } + + private static Map child(Map owner, String key) { + if (owner == null) { + return null; + } + Object value = owner.get(key); + return value instanceof Map map ? map : null; + } + + private static Map yaml(String resource) throws IOException { + String source = + Files.readString( + REPOSITORY_ROOT.resolve("src/app-bootstrap/src/main/resources").resolve(resource)); + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + // The base file is placeholder-driven; resolving is Spring's job, parsing is all this needs. + Object loaded = new Yaml(new SafeConstructor(options)).load(source); + assertThat(loaded).as("%s must parse as a YAML mapping", resource).isInstanceOf(Map.class); + return (Map) loaded; + } + + private static Path repositoryRoot() { + for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent()) { + if (Files.isRegularFile(path.resolve("AGENTS.md")) + && Files.isRegularFile(path.resolve("src/settings.gradle"))) { + return path; + } + } + throw new IllegalStateException("repository root not found from " + Paths.get("").toAbsolutePath()); + } + + /** + * The local profile must not need src/.env. Only {@code ./gradlew :app-bootstrap:bootRun} injects + * that file; an IDE Run/Debug, {@code java -jar} and a bare container do not, and an unresolved + * {@code ${APP_*}} placeholder fails property binding. The failure is worse than it sounds: + * logback-spring.xml reads the same unresolved {@code ca-skeleton.logging.*} values, so the + * logging system dies first and the process exits having printed nothing at all. + */ + @Test + void localProfileAnswersEveryRequiredPlaceholder() throws IOException { + Map localValues = flatten(profile("local")); + + List unanswered = + placeholders().entrySet().stream() + .filter(entry -> entry.getValue().inlineDefault() == null) + .map(Map.Entry::getKey) + .filter(path -> !localValues.containsKey(path)) + .sorted() + .toList(); + + assertThat(unanswered) + .as("application-local.yml must restate every value application.yml demands from src/.env") + .isEmpty(); + } + + /** + * And where a placeholder does have an inline default, local must not silently disagree with the + * repository's own local values. {@code PRESENTATION_API_BASE_PATH} is why: application.yml + * defaults it to {@code /v1} while src/.env says {@code /api}, so a launcher without src/.env + * served {@code /v1/healthcheck} and answered 401 on the {@code /api/healthcheck} address the + * README, the compose healthchecks and SECURITY_PUBLIC_PATHS all name. + */ + @Test + void localProfilePinsEveryValueWhoseInlineDefaultDisagreesWithTheCommittedEnvironment() + throws IOException { + Map environment = committedEnvironment(); + Map localValues = flatten(profile("local")); + + List divergent = + placeholders().entrySet().stream() + .filter(entry -> entry.getValue().inlineDefault() != null) + .filter(entry -> environment.containsKey(entry.getValue().variable())) + .filter( + entry -> + !environment + .get(entry.getValue().variable()) + .equals(entry.getValue().inlineDefault())) + .map(Map.Entry::getKey) + .filter(path -> !localValues.containsKey(path)) + .sorted() + .toList(); + + assertThat(divergent) + .as("local must behave the same whether or not the launcher injected src/.env") + .isEmpty(); + } + + private record Placeholder(String variable, String inlineDefault) {} + + /** Property path → the {@code ${VAR}} or {@code ${VAR:default}} application.yml resolves it from. */ + private static Map placeholders() throws IOException { + Pattern syntax = Pattern.compile("^\\$\\{([A-Z0-9_]+)(?::(.*))?}$"); + Map found = new LinkedHashMap<>(); + Deque indents = new ArrayDeque<>(); + Deque names = new ArrayDeque<>(); + + for (String raw : + Files.readAllLines( + REPOSITORY_ROOT.resolve("src/app-bootstrap/src/main/resources/application.yml"))) { + String line = raw.strip(); + if (line.isEmpty() || line.startsWith("#") || !line.contains(":")) { + continue; + } + int indent = raw.length() - raw.stripLeading().length(); + int separator = line.indexOf(':'); + String key = line.substring(0, separator).strip(); + String value = line.substring(separator + 1).strip(); + while (!indents.isEmpty() && indents.peek()[0] >= indent) { + indents.pop(); + names.pop(); + } + if (value.isEmpty()) { + indents.push(new int[] {indent}); + names.push(key); + continue; + } + Matcher matcher = syntax.matcher(value); + if (!matcher.matches()) { + continue; + } + List path = new ArrayList<>(names); + Collections.reverse(path); + path.add(key); + found.put(String.join(".", path), new Placeholder(matcher.group(1), matcher.group(2))); + } + return found; + } + + private static Map committedEnvironment() throws IOException { + Map values = new LinkedHashMap<>(); + for (String raw : Files.readAllLines(REPOSITORY_ROOT.resolve("src/.env"))) { + String line = raw.strip(); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separator = line.indexOf('='); + values.put(line.substring(0, separator).strip(), line.substring(separator + 1).strip()); + } + return values; + } + + private static Map flatten(Map configuration) { + Map flat = new LinkedHashMap<>(); + flatten("", configuration, flat); + return flat; + } + + private static void flatten(String prefix, Map node, Map into) { + node.forEach( + (key, value) -> { + String path = prefix.isEmpty() ? String.valueOf(key) : prefix + "." + key; + if (value instanceof Map child) { + flatten(path, child, into); + } else { + into.put(path, String.valueOf(value)); + } + }); + } + + @Test + void everyDeclaredProfileHasAConfigurationFile() { + List profiles = List.of("local", "dev", "prod"); + + assertThat(profiles) + .allSatisfy( + profile -> + assertThat( + REPOSITORY_ROOT.resolve( + "src/app-bootstrap/src/main/resources/application-" + profile + ".yml")) + .exists()); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderScanCollisionTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderScanCollisionTest.java new file mode 100644 index 0000000..9b68344 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderScanCollisionTest.java @@ -0,0 +1,171 @@ +package dev.caskeleton.bootstrap.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.idempotency.IdempotencyExecutor; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.v2.IdempotencyStorePortV2; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcOperations; + +/** + * The provider guard, against the beans the component scan actually produces. + * + *

Every other test of this guard hands it the beans it expects. That is precisely the shape of + * test that cannot see the defect it is guarding against, because the question is not "does the + * guard count correctly" but "what is in the context to count". {@code CaSkeletonApplication} scans + * {@code dev.caskeleton.adapter}, and {@code PostgreSqlOwnerSafeIdempotencyStore} is a + * {@code @Repository} implementing the owner-safe V2 contract — so it is registered in every + * deployment, whichever provider was selected, and no test in the repository booted a context that + * would notice. + * + *

Both selections are asserted, because the collision breaks both: {@code jdbc} acquires a V2 + * store it never asked for, and {@code redis} acquires a second one beside its own. + */ +class IdempotencyProviderScanCollisionTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withUserConfiguration(ScannedPersistence.class, IdempotencyProviderSelectionConfig.class) + .withBean(JdbcOperations.class, () -> org.mockito.Mockito.mock(JdbcOperations.class)); + + @Test + @DisplayName("the default JDBC provider composes exactly its own V1 pair and no V2 store") + void theJdbcProviderComposesOnlyV1() { + runner + .withPropertyValues("ca-skeleton.capabilities.idempotency.provider=jdbc") + .withBean( + IdempotencyStorePort.class, () -> org.mockito.Mockito.mock(IdempotencyStorePort.class)) + .withBean( + IdempotencyExecutor.class, () -> org.mockito.Mockito.mock(IdempotencyExecutor.class)) + .run( + context -> { + assertThat(context) + .as("the default provider must not acquire an owner-safe V2 store by scan") + .hasNotFailed(); + assertThat(context.getBeansOfType(IdempotencyStorePortV2.class)).isEmpty(); + }); + } + + @Test + @DisplayName("the Redis provider composes exactly one owner-safe V2 store and its executor") + void theRedisProviderComposesOneV2StoreAndItsExecutor() { + runner + .withPropertyValues("ca-skeleton.capabilities.idempotency.provider=redis") + .withBean(IdempotencyStorePortV2.class, RedisLikeStore::new) + .withBean( + dev.caskeleton.application.idempotency.v2.IdempotencyExecutorV2.class, + () -> + new dev.caskeleton.application.idempotency.v2.IdempotencyExecutorV2( + new RedisLikeStore(), + java.time.Duration.ofSeconds(30), + java.time.Duration.ofHours(1), + java.time.Duration.ofHours(1), + "json-v2", + 1)) + .run( + context -> { + assertThat(context) + .as("a second V2 store from the scan makes the selection ambiguous") + .hasNotFailed(); + assertThat(context.getBeansOfType(IdempotencyStorePortV2.class)).hasSize(1); + }); + } + + @Test + @DisplayName("a V2 store with no executor is refused: nothing could drive it") + void aStoreWithoutAnExecutorIsRefused() { + runner + .withPropertyValues("ca-skeleton.capabilities.idempotency.provider=redis") + .withBean(IdempotencyStorePortV2.class, RedisLikeStore::new) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("owner-safe V2 executor"); + }); + } + + /** Exactly what the composition root scans, narrowed to the package under test. */ + @Configuration(proxyBeanMethods = false) + @ComponentScan("dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency") + static class ScannedPersistence { + + @Bean + IdempotencySettings idempotencySettings() { + return new IdempotencySettings(null, null); + } + } + + /** Stands in for the Redis V2 store, whose own composition is covered elsewhere. */ + private static final class RedisLikeStore implements IdempotencyStorePortV2 { + + @Override + public dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt newClaimAttempt( + dev.caskeleton.application.transaction.OperationId operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public dev.caskeleton.application.idempotency.v2.IdempotencyClaimOutcome claim( + dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public dev.caskeleton.application.idempotency.v2.IdempotencyMutationResult< + dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome> + markExecutionStarted( + dev.caskeleton.application.idempotency.v2.IdempotencyOwner owner, + dev.caskeleton.application.transaction.OperationId operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public dev.caskeleton.application.idempotency.v2.IdempotencyMutationResult< + dev.caskeleton.application.idempotency.v2.IdempotencyRenewOutcome> + renew( + dev.caskeleton.application.idempotency.v2.IdempotencyOwner owner, + java.time.Duration processingLeaseTtl, + dev.caskeleton.application.transaction.OperationId operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public dev.caskeleton.application.idempotency.v2.IdempotencyCompleteOutcome complete( + dev.caskeleton.application.idempotency.v2.IdempotencyOwner owner, + dev.caskeleton.application.idempotency.StoredResponse response, + java.time.Duration replayTtl, + dev.caskeleton.application.transaction.OperationId operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public dev.caskeleton.application.idempotency.v2.IdempotencyFailOutcome markFailed( + dev.caskeleton.application.idempotency.v2.IdempotencyOwner owner, + dev.caskeleton.application.idempotency.v2.IdempotencyFailureDisposition disposition, + java.time.Duration retention, + dev.caskeleton.application.transaction.OperationId operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public dev.caskeleton.application.idempotency.v2.IdempotencyReleaseOutcome + releaseBeforeExecution( + dev.caskeleton.application.idempotency.v2.IdempotencyOwner owner, + dev.caskeleton.application.transaction.OperationId operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public dev.caskeleton.application.idempotency.v2.IdempotencyInspection inspect( + dev.caskeleton.application.idempotency.v2.IdempotencyInspectionRequest request) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/PersistenceVendorProdSafetyValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/PersistenceVendorProdSafetyValidatorTest.java new file mode 100644 index 0000000..45330d8 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/PersistenceVendorProdSafetyValidatorTest.java @@ -0,0 +1,101 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.runtime.startup.ProfileMismatchException; +import java.io.PrintWriter; +import java.io.StringWriter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +class PersistenceVendorProdSafetyValidatorTest { + + private static final String POSTGRESQL_URL = "jdbc:postgresql://db:5432/ca_skeleton"; + private static final String H2_URL = "jdbc:h2:mem:ca_skeleton;MODE=PostgreSQL"; + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + @ParameterizedTest + @ValueSource(strings = {"h2", "H2", " h2 "}) + void prodRejectsTheH2VendorSelector(String vendor) { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues( + PersistenceVendorProdSafetyValidator.VENDOR_KEY + "=" + vendor, + PersistenceVendorProdSafetyValidator.JDBC_URL_KEY + "=" + POSTGRESQL_URL) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(ProfileMismatchException.class) + .hasStackTraceContaining(PersistenceVendorProdSafetyValidator.VENDOR_KEY); + }); + } + + @Test + void prodRejectsAnH2UrlEvenWhenTheSelectorSaysPostgreSql() { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues( + PersistenceVendorProdSafetyValidator.VENDOR_KEY + "=postgresql", + PersistenceVendorProdSafetyValidator.JDBC_URL_KEY + "=" + H2_URL) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(ProfileMismatchException.class) + .hasStackTraceContaining(PersistenceVendorProdSafetyValidator.JDBC_URL_ENV_KEY); + }); + } + + @Test + void prodFailureNeverEchoesTheJdbcUrl() { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(PersistenceVendorProdSafetyValidator.JDBC_URL_KEY + "=" + H2_URL) + .run( + context -> { + StringWriter trace = new StringWriter(); + context.getStartupFailure().printStackTrace(new PrintWriter(trace)); + assertThat(trace.toString()) + .as("a JDBC URL can carry credentials, endpoints and database names") + .doesNotContain(H2_URL); + }); + } + + @Test + void prodAcceptsPostgreSql() { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues( + PersistenceVendorProdSafetyValidator.VENDOR_KEY + "=postgresql", + PersistenceVendorProdSafetyValidator.JDBC_URL_KEY + "=" + POSTGRESQL_URL) + .run(context -> assertThat(context).hasNotFailed()); + } + + @ParameterizedTest + @ValueSource(strings = {"local", "dev", "stage"}) + void nonProdProfilesMayRunH2(String profile) { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles(profile)) + .withPropertyValues( + PersistenceVendorProdSafetyValidator.VENDOR_KEY + "=h2", + PersistenceVendorProdSafetyValidator.JDBC_URL_KEY + "=" + H2_URL) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Configuration + static class ValidatorConfig { + + @Bean + PersistenceVendorProdSafetyValidator validator(Environment environment) { + return new PersistenceVendorProdSafetyValidator(environment); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInFlightException.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInFlightException.java index 643fd4f..041738d 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInFlightException.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInFlightException.java @@ -13,7 +13,24 @@ public class IdempotencyInFlightException extends RuntimeException { private final transient IdempotencyScope scope; public IdempotencyInFlightException(IdempotencyScope scope) { - super("idempotent request still in flight: " + (scope == null ? "" : scope.storageKey())); + this(scope == null ? "" : scope.storageKey(), scope); + } + + /** + * Creates the exception for the owner-safe lifecycle, which never holds the raw scope: the + * principal and the client key are digested before they reach the store, so the opaque digest + * diagnostic is the only identity available. {@link #scope()} is {@code null} on this path, as it + * already is for any deserialized instance — the field is {@code transient}. + * + * @param scopeDigestDiagnostic opaque scope-digest diagnostic, for logs only + * @return the exception + */ + public static IdempotencyInFlightException forScopeDigest(String scopeDigestDiagnostic) { + return new IdempotencyInFlightException(scopeDigestDiagnostic, null); + } + + private IdempotencyInFlightException(String scopeDiagnostic, IdempotencyScope scope) { + super("idempotent request still in flight: " + scopeDiagnostic); this.scope = scope; } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRequestMismatchException.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRequestMismatchException.java index c9c0ab0..4b9d9ab 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRequestMismatchException.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRequestMismatchException.java @@ -13,9 +13,24 @@ public class IdempotencyRequestMismatchException extends RuntimeException { private final transient IdempotencyScope scope; public IdempotencyRequestMismatchException(IdempotencyScope scope) { - super( - "idempotency key reused with a different request body: " - + (scope == null ? "" : scope.storageKey())); + this(scope == null ? "" : scope.storageKey(), scope); + } + + /** + * Creates the exception for the owner-safe lifecycle, which never holds the raw scope: the + * principal and the client key are digested before they reach the store, so the opaque digest + * diagnostic is the only identity available. {@link #scope()} is {@code null} on this path, as it + * already is for any deserialized instance — the field is {@code transient}. + * + * @param scopeDigestDiagnostic opaque scope-digest diagnostic, for logs only + * @return the exception + */ + public static IdempotencyRequestMismatchException forScopeDigest(String scopeDigestDiagnostic) { + return new IdempotencyRequestMismatchException(scopeDigestDiagnostic, null); + } + + private IdempotencyRequestMismatchException(String scopeDiagnostic, IdempotencyScope scope) { + super("idempotency key reused with a different request body: " + scopeDiagnostic); this.scope = scope; } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java new file mode 100644 index 0000000..35255fe --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java @@ -0,0 +1,328 @@ +package dev.caskeleton.application.idempotency.v2; + +import dev.caskeleton.application.idempotency.IdempotencyInFlightException; +import dev.caskeleton.application.idempotency.IdempotencyRecoveryRequiredException; +import dev.caskeleton.application.idempotency.IdempotencyRequestMismatchException; +import dev.caskeleton.application.idempotency.IdempotencyUnavailableException; +import dev.caskeleton.application.idempotency.IdempotentAction; +import dev.caskeleton.application.idempotency.IdempotentResponseCodec; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import dev.caskeleton.application.transaction.OperationId; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * Owner-safe request-replay lifecycle, over the contract the providers actually implement. + * + *

There were two V2 store contracts with the same name in neighbouring packages, and this + * orchestration was written against the one nothing implements. Both owner-safe stores — PostgreSQL + * and Redis — implement {@link IdempotencyStorePortV2} here, so selecting a V2 provider required a + * bean that could not exist and the executor could never be composed at all. + * + *

Porting it was not a rename. This contract threads the owner handle through every mutation: a + * confirmed transition returns the owner with its state revision advanced, and the next call must + * present that handle. Re-using the claim's original owner would present a stale revision, + * which the store is built to refuse — that refusal is the whole point of the compare-and-set, so a + * caller that defeats it has an owner-safe store and no owner safety. + * + *

The action runs only after a confirmed start. This preserves request-replay evidence; it does + * not create a cross-store exactly-once boundary, and nothing here should be read as claiming one. + */ +public final class IdempotencyExecutorV2 { + + private final IdempotencyStorePortV2 store; + private final Duration processingLeaseTtl; + private final Duration replayTtl; + private final Duration failureRetention; + private final String responseCodecId; + private final int policyRevision; + + /** + * Creates the executor. + * + * @param store the owner-safe store + * @param processingLeaseTtl how long a claim holds the record before another caller may take over + * @param replayTtl how long a completed response is replayable + * @param failureRetention how long a failed attempt's evidence is kept + * @param responseCodecId the identifier of the codec the stored payload was written with + * @param policyRevision the replay policy revision this deployment enforces + */ + public IdempotencyExecutorV2( + IdempotencyStorePortV2 store, + Duration processingLeaseTtl, + Duration replayTtl, + Duration failureRetention, + String responseCodecId, + int policyRevision) { + this.store = Objects.requireNonNull(store, "store must be non-null"); + this.processingLeaseTtl = + Objects.requireNonNull(processingLeaseTtl, "processing lease TTL must be non-null"); + this.replayTtl = Objects.requireNonNull(replayTtl, "replay TTL must be non-null"); + this.failureRetention = + Objects.requireNonNull(failureRetention, "failure retention must be non-null"); + this.responseCodecId = + Objects.requireNonNull(responseCodecId, "response codec identifier must be non-null"); + this.policyRevision = policyRevision; + if (failureRetention.isZero() || failureRetention.isNegative()) { + throw new IllegalArgumentException("the failure retention must be positive"); + } + } + + /** + * Mints the attempt a caller retains across a lost response. + * + * @param operationId the caller's operation identifier + * @return the claim attempt + */ + public IdempotencyClaimAttempt newAttempt(OperationId operationId) { + return store.newClaimAttempt(operationId); + } + + /** + * Claims the scope, runs the action once, and stores its response for replay. + * + * @param scope the digested scope; the raw client key never reaches this class + * @param fingerprint the request fingerprint a replay is checked against + * @param attempt the retained claim attempt + * @param action the action to run at most once + * @param codec serialises and deserialises the action's result + * @param the action's result type + * @return the action's result, or the replayed one + */ + public R execute( + IdempotencyScopeDigest scope, + RequestFingerprint fingerprint, + IdempotencyClaimAttempt attempt, + IdempotentAction action, + IdempotentResponseCodec codec) { + Objects.requireNonNull(action, "action must be non-null"); + Objects.requireNonNull(codec, "codec must be non-null"); + IdempotencyClaimRequest request = + new IdempotencyClaimRequest( + scope, + fingerprint, + attempt, + processingLeaseTtl, + replayTtl, + responseCodecId, + policyRevision); + + return switch (store.claim(request)) { + case IdempotencyClaimOutcome.CompletedReplay replay -> + codec.deserialize(replay.response().payload()); + case IdempotencyClaimOutcome.FingerprintMismatch ignored -> + throw IdempotencyRequestMismatchException.forScopeDigest(diagnostic(scope)); + case IdempotencyClaimOutcome.InProgress ignored -> + throw IdempotencyInFlightException.forScopeDigest(diagnostic(scope)); + case IdempotencyClaimOutcome.RecoveryRequired ignored -> + throw recovery("claim requires reconciliation"); + case IdempotencyClaimOutcome.OwnerOperationConflict ignored -> + throw recovery("claim requires reconciliation"); + case IdempotencyClaimOutcome.Unavailable ignored -> + throw new IdempotencyUnavailableException(); + // The response was lost, not the attempt. The retained attempt is what makes the record + // findable, so the only safe move is to ask the store what actually happened. + case IdempotencyClaimOutcome.Indeterminate ignored -> reconcile(request, action, codec); + case IdempotencyClaimOutcome.Acquired acquired -> + startAndRun(request, acquired.owner(), action, codec, false); + case IdempotencyClaimOutcome.ReplayedAcquire replayed -> + startAndRun(request, replayed.owner(), action, codec, false); + case IdempotencyClaimOutcome.TakenOverClaimed takenOver -> + startAndRun(request, takenOver.owner(), action, codec, false); + }; + } + + private R reconcile( + IdempotencyClaimRequest request, + IdempotentAction action, + IdempotentResponseCodec codec) { + IdempotencyInspection inspection = inspect(request); + return switch (inspection.outcome()) { + case CLAIMED_SAME_OPERATION -> + startAndRun(request, requireOwner(inspection), action, codec, false); + case EXECUTING_SAME_OPERATION -> runStarted(request, requireOwner(inspection), action, codec); + case COMPLETED_REPLAY -> codec.deserialize(requireResponse(inspection).payload()); + case FINGERPRINT_MISMATCH -> + throw IdempotencyRequestMismatchException.forScopeDigest(diagnostic(request.scope())); + case IN_PROGRESS_OTHER -> + throw IdempotencyInFlightException.forScopeDigest(diagnostic(request.scope())); + case UNAVAILABLE -> throw new IdempotencyUnavailableException(); + // ABSENT, FAILED_RETRYABLE, ABANDONED and OPERATION_CONFLICT all mean the same thing here: + // this caller cannot prove what happened to its own attempt, and guessing is the one thing an + // owner-safe store exists to prevent. + default -> throw recovery("an indeterminate claim could not be resumed safely"); + }; + } + + private R startAndRun( + IdempotencyClaimRequest request, + IdempotencyOwner owner, + IdempotentAction action, + IdempotentResponseCodec codec, + boolean alreadyRetried) { + IdempotencyMutationResult started = + store.markExecutionStarted(owner, request.claimAttempt().operationId()); + return switch (started.outcome()) { + // The owner handle from the transition, not the one from the claim: the state revision has + // advanced and the next compare-and-set is against the new one. + case STARTED, ALREADY_STARTED_SAME_OPERATION -> + runStarted(request, advanced(started, owner), action, codec); + case UNAVAILABLE -> throw new IdempotencyUnavailableException(); + case INDETERMINATE -> { + if (alreadyRetried) { + throw recovery("execution start stayed indeterminate after reconciliation"); + } + yield resumeAfterIndeterminateStart(request, action, codec); + } + default -> throw recovery("execution start was not confirmed for this exact operation"); + }; + } + + private R resumeAfterIndeterminateStart( + IdempotencyClaimRequest request, + IdempotentAction action, + IdempotentResponseCodec codec) { + IdempotencyInspection inspection = inspect(request); + return switch (inspection.outcome()) { + case CLAIMED_SAME_OPERATION -> + startAndRun(request, requireOwner(inspection), action, codec, true); + case EXECUTING_SAME_OPERATION -> runStarted(request, requireOwner(inspection), action, codec); + case COMPLETED_REPLAY -> codec.deserialize(requireResponse(inspection).payload()); + case UNAVAILABLE -> throw new IdempotencyUnavailableException(); + default -> throw recovery("execution start is indeterminate"); + }; + } + + private R runStarted( + IdempotencyClaimRequest request, + IdempotencyOwner owner, + IdempotentAction action, + IdempotentResponseCodec codec) { + OperationId operationId = request.claimAttempt().operationId(); + IdempotentAction.Outcome outcome; + try { + outcome = Objects.requireNonNull(action.run(), "action outcome must be non-null"); + } catch (RuntimeException failure) { + // The action threw without saying whether it had an effect, so the record must not say + // "retryable": that would invite a second execution of something that may already have run. + preserveUnknown(owner, operationId); + throw failure; + } + return switch (outcome) { + case IdempotentAction.Outcome.Success success -> + complete(request, owner, success.result(), codec, false); + case IdempotentAction.Outcome.RetryableNoEffect retryable -> { + store.markFailed( + owner, + IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE, + failureRetention, + operationId); + throw retryable.failure(); + } + case IdempotentAction.Outcome.EffectUnknown unknown -> { + preserveUnknown(owner, operationId); + throw unknown.failure(); + } + }; + } + + private R complete( + IdempotencyClaimRequest request, + IdempotencyOwner owner, + R result, + IdempotentResponseCodec codec, + boolean alreadyRetried) { + StoredResponse response = new StoredResponse(codec.serialize(result)); + return switch (store.complete( + owner, response, replayTtl, request.claimAttempt().operationId())) { + case COMPLETED, ALREADY_COMPLETED_SAME_RESULT -> result; + case UNAVAILABLE -> throw new IdempotencyUnavailableException(); + case INDETERMINATE -> reconcileCompletion(request, result, codec, alreadyRetried); + default -> throw recovery("completion was not confirmed"); + }; + } + + /** + * Resolves a completion whose response was lost. + * + *

Deliberately does not carry the caller's owner handle forward: after an indeterminate + * completion the caller's handle may already be stale, and the only handle worth presenting is + * the one the store reports now. + */ + private R reconcileCompletion( + IdempotencyClaimRequest request, + R result, + IdempotentResponseCodec codec, + boolean alreadyRetried) { + IdempotencyInspection inspection = inspect(request); + return switch (inspection.outcome()) { + case COMPLETED_REPLAY -> { + StoredResponse stored = requireResponse(inspection); + if (stored.payload().equals(codec.serialize(result))) { + yield result; + } + // The stored response is somebody else's answer to this scope. Returning either one would + // be asserting a fact this caller cannot establish. + throw recovery("the completed response conflicts with the one this caller produced"); + } + case EXECUTING_SAME_OPERATION -> { + if (alreadyRetried) { + throw recovery("completion stayed indeterminate after reconciliation"); + } + yield complete(request, requireOwner(inspection), result, codec, true); + } + case UNAVAILABLE -> throw new IdempotencyUnavailableException(); + default -> throw recovery("the completion response is indeterminate and was not reconciled"); + }; + } + + private void preserveUnknown(IdempotencyOwner owner, OperationId operationId) { + store.markFailed( + owner, + IdempotencyFailureDisposition.EFFECT_UNKNOWN_ABANDONED, + failureRetention, + operationId); + } + + private IdempotencyInspection inspect(IdempotencyClaimRequest request) { + return store.inspect( + new IdempotencyInspectionRequest( + request.scope(), request.requestFingerprint(), request.claimAttempt())); + } + + private static IdempotencyOwner advanced( + IdempotencyMutationResult transition, IdempotencyOwner fallback) { + return transition.owner().orElse(fallback); + } + + private static IdempotencyOwner requireOwner(IdempotencyInspection inspection) { + Optional owner = inspection.owner(); + return owner.orElseThrow( + () -> + recovery( + "the store reported " + + inspection.outcome() + + " without the owner handle it needs")); + } + + private static StoredResponse requireResponse(IdempotencyInspection inspection) { + return inspection + .response() + .orElseThrow(() -> recovery("the store reported a completed replay with no response")); + } + + /** + * Renders the scope for a diagnostic. Every component is already opaque — the raw client key and + * principal were digested before the scope was constructed — so this is safe to log, and it is + * the only scope identity this lifecycle ever holds. + */ + private static String diagnostic(IdempotencyScopeDigest scope) { + return scope.operationCode() + "::v" + scope.keyDigestVersion() + "::" + scope.digest(); + } + + private static IdempotencyRecoveryRequiredException recovery(String message) { + return new IdempotencyRecoveryRequiredException(message); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2Test.java b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2Test.java new file mode 100644 index 0000000..4bd7147 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2Test.java @@ -0,0 +1,343 @@ +package dev.caskeleton.application.idempotency.v2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.idempotency.IdempotentAction; +import dev.caskeleton.application.idempotency.IdempotentResponseCodec; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import dev.caskeleton.application.transaction.OperationId; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The request-replay lifecycle, over the contract the stores implement. + * + *

Carried over from a test of the same name that exercised the duplicate V2 contract in the + * parent package — the one nothing implements. The behaviours below are the ones worth keeping: the + * action runs at most once, a lost response is reconciled rather than re-run, and an outcome the + * caller cannot classify is never recorded as "no effect". + * + *

One behaviour is new, because the contract is: every confirmed transition hands back the owner + * with its state revision advanced, and the next call must present that handle rather than the one + * the claim returned. + */ +class IdempotencyExecutorV2Test { + + private static final String OWNER_TOKEN = "b".repeat(64); + private static final OperationId OPERATION = new OperationId("operation-aaaaaaaaaa"); + private static final IdempotencyScopeDigest SCOPE = + new IdempotencyScopeDigest("a".repeat(64), 1, "CREATE_WORKLOG"); + private static final RequestFingerprint FINGERPRINT = new RequestFingerprint("c".repeat(64)); + private static final IdempotencyClaimAttempt ATTEMPT = + new IdempotencyClaimAttempt(OWNER_TOKEN, OPERATION); + private static final Instant LEASE_UNTIL = Instant.parse("2026-07-29T12:00:30Z"); + + private static IdempotencyOwner owner(long stateRevision) { + return new IdempotencyOwner(SCOPE, OWNER_TOKEN, 1, stateRevision, OPERATION); + } + + @Test + @DisplayName("the action runs only after a confirmed start, and then completes") + void actionRunsOnlyAfterAConfirmedStart() { + FakeStore store = new FakeStore(); + store.claim = new IdempotencyClaimOutcome.Acquired(owner(0), LEASE_UNTIL); + store.start = IdempotencyStartOutcome.STARTED; + AtomicBoolean ran = new AtomicBoolean(); + + String result = + executor(store) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> { + ran.set(true); + return new IdempotentAction.Outcome.Success<>("created"); + }, + codec()); + + assertThat(result).isEqualTo("created"); + assertThat(ran).isTrue(); + assertThat(store.completeCalls).isEqualTo(1); + assertThat(store.failedCalls).isZero(); + } + + @Test + @DisplayName("each mutation presents the owner handle the previous one returned") + void theAdvancedOwnerHandleIsCarriedForward() { + // The contract's whole point. The store advances the state revision on a confirmed transition + // and refuses a stale one; a caller that kept presenting the claim's original handle would be + // refused by its own store, so an owner-safe store would buy nothing. + FakeStore store = new FakeStore(); + store.claim = new IdempotencyClaimOutcome.Acquired(owner(0), LEASE_UNTIL); + store.start = IdempotencyStartOutcome.STARTED; + store.startOwner = owner(1); + + executor(store) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> new IdempotentAction.Outcome.Success<>("created"), + codec()); + + assertThat(store.completeOwner.stateRevision()) + .as("complete must present the revision the start returned, not the claim's") + .isEqualTo(1); + } + + @Test + @DisplayName("an indeterminate claim is reconciled rather than re-run") + void anIndeterminateClaimIsReconciled() { + FakeStore store = new FakeStore(); + store.claim = new IdempotencyClaimOutcome.Indeterminate(OPERATION); + store.inspection = + new IdempotencyInspection( + IdempotencyInspectionOutcome.EXECUTING_SAME_OPERATION, + Optional.of(owner(1)), + Optional.of(LEASE_UNTIL), + Optional.empty(), + Optional.empty()); + AtomicBoolean ran = new AtomicBoolean(); + + assertThat( + executor(store) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> { + ran.set(true); + return new IdempotentAction.Outcome.Success<>("created"); + }, + codec())) + .isEqualTo("created"); + assertThat(ran).isTrue(); + assertThat(store.startCalls) + .as("the record already says EXECUTING; starting it again would be a second transition") + .isZero(); + } + + @Test + @DisplayName("a lost completion is inspected without running the action a second time") + void aLostCompletionIsInspectedNotRepeated() { + FakeStore store = startedStore(); + store.complete = IdempotencyCompleteOutcome.INDETERMINATE; + store.inspectionAfterComplete = + new IdempotencyInspection( + IdempotencyInspectionOutcome.COMPLETED_REPLAY, + Optional.empty(), + Optional.empty(), + Optional.of(new StoredResponse("created")), + Optional.of(Instant.parse("2026-07-29T13:00:00Z"))); + int[] actionCalls = {0}; + + assertThat( + executor(store) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> { + actionCalls[0]++; + return new IdempotentAction.Outcome.Success<>("created"); + }, + codec())) + .isEqualTo("created"); + assertThat(actionCalls[0]).isEqualTo(1); + assertThat(store.completeCalls).isEqualTo(1); + } + + @Test + @DisplayName("a replayed response that disagrees with this caller's is a recovery, not a result") + void aConflictingReplayIsNotReturned() { + FakeStore store = startedStore(); + store.complete = IdempotencyCompleteOutcome.INDETERMINATE; + store.inspectionAfterComplete = + new IdempotencyInspection( + IdempotencyInspectionOutcome.COMPLETED_REPLAY, + Optional.empty(), + Optional.empty(), + Optional.of(new StoredResponse("somebody-elses-answer")), + Optional.of(Instant.parse("2026-07-29T13:00:00Z"))); + + assertThatThrownBy( + () -> + executor(store) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> new IdempotentAction.Outcome.Success<>("created"), + codec())) + .hasMessageContaining("conflicts"); + } + + @Test + @DisplayName("an unclassified throw and an unknown effect are never recorded as no-effect") + void unknownEffectsAreNeverDiscarded() { + FakeStore thrown = startedStore(); + RuntimeException unclassified = new IllegalStateException("unknown effect"); + + assertThatThrownBy( + () -> + executor(thrown) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> { + throw unclassified; + }, + codec())) + .isSameAs(unclassified); + assertThat(thrown.lastDisposition) + .isEqualTo(IdempotencyFailureDisposition.EFFECT_UNKNOWN_ABANDONED); + + FakeStore declared = startedStore(); + RuntimeException classified = new IllegalArgumentException("provider response unknown"); + assertThatThrownBy( + () -> + executor(declared) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> new IdempotentAction.Outcome.EffectUnknown<>(classified), + codec())) + .isSameAs(classified); + assertThat(declared.lastDisposition) + .isEqualTo(IdempotencyFailureDisposition.EFFECT_UNKNOWN_ABANDONED); + } + + @Test + @DisplayName("only an explicitly effect-free failure is recorded as retryable") + void onlyDeclaredNoEffectIsRetryable() { + FakeStore store = startedStore(); + RuntimeException failure = new IllegalArgumentException("validation"); + + assertThatThrownBy( + () -> + executor(store) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> new IdempotentAction.Outcome.RetryableNoEffect<>(failure), + codec())) + .isSameAs(failure); + assertThat(store.lastDisposition).isEqualTo(IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE); + } + + private static FakeStore startedStore() { + FakeStore store = new FakeStore(); + store.claim = new IdempotencyClaimOutcome.Acquired(owner(0), LEASE_UNTIL); + store.start = IdempotencyStartOutcome.STARTED; + return store; + } + + private static IdempotencyExecutorV2 executor(FakeStore store) { + return new IdempotencyExecutorV2( + store, Duration.ofSeconds(30), Duration.ofHours(1), Duration.ofHours(1), "json-v2", 1); + } + + private static IdempotentResponseCodec codec() { + return new IdempotentResponseCodec<>() { + @Override + public String serialize(String result) { + return result; + } + + @Override + public String deserialize(String payload) { + return payload; + } + }; + } + + private static final class FakeStore implements IdempotencyStorePortV2 { + + private IdempotencyClaimOutcome claim; + private IdempotencyStartOutcome start; + private IdempotencyOwner startOwner; + private IdempotencyCompleteOutcome complete = IdempotencyCompleteOutcome.COMPLETED; + private IdempotencyInspection inspection = + IdempotencyInspection.outcome(IdempotencyInspectionOutcome.UNAVAILABLE); + private IdempotencyInspection inspectionAfterComplete; + private int startCalls; + private int completeCalls; + private int failedCalls; + private IdempotencyOwner completeOwner; + private IdempotencyFailureDisposition lastDisposition; + + @Override + public IdempotencyClaimAttempt newClaimAttempt(OperationId operationId) { + return new IdempotencyClaimAttempt(OWNER_TOKEN, operationId); + } + + @Override + public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) { + return claim; + } + + @Override + public IdempotencyMutationResult markExecutionStarted( + IdempotencyOwner owner, OperationId operationId) { + startCalls++; + IdempotencyOwner advanced = + start.carriesOwner() ? (startOwner == null ? owner : startOwner) : null; + return new IdempotencyMutationResult<>( + start, advanced, IdempotencyStartOutcome::carriesOwner); + } + + @Override + public IdempotencyMutationResult renew( + IdempotencyOwner owner, Duration processingLeaseTtl, OperationId operationId) { + throw new UnsupportedOperationException("the executor does not renew"); + } + + @Override + public IdempotencyCompleteOutcome complete( + IdempotencyOwner owner, + StoredResponse response, + Duration replayTtl, + OperationId operationId) { + completeCalls++; + completeOwner = owner; + if (inspectionAfterComplete != null) { + inspection = inspectionAfterComplete; + } + return complete; + } + + @Override + public IdempotencyFailOutcome markFailed( + IdempotencyOwner owner, + IdempotencyFailureDisposition disposition, + Duration retention, + OperationId operationId) { + failedCalls++; + lastDisposition = disposition; + return disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE + ? IdempotencyFailOutcome.MARKED_RETRYABLE + : IdempotencyFailOutcome.MARKED_ABANDONED; + } + + @Override + public IdempotencyReleaseOutcome releaseBeforeExecution( + IdempotencyOwner owner, OperationId operationId) { + throw new UnsupportedOperationException("the executor never releases before execution"); + } + + @Override + public IdempotencyInspection inspect(IdempotencyInspectionRequest request) { + return inspection; + } + } +} diff --git a/src/build.gradle b/src/build.gradle index 9e1d08c..e90a58c 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -232,8 +232,10 @@ def verifySpotBugsAnalysisFailureContract = throw new GradleException( 'verifySpotBugsAnalysisFailureContract: analysis report verifier is not configured') } - Closure> analysisFailures = - rootProject.ext.spotBugsAnalysisFailures as Closure> + // Raw Closure on purpose. The parameterised form, wrapped across two lines, is + // valid Groovy and Gradle runs it, but the IDE's Gradle parser reads the trailing + // `>>` as the end of a block and reports a syntax error for the rest of the file. + def analysisFailures = rootProject.ext.spotBugsAnalysisFailures as Closure Map fixtures = [ clean : '', missing : 'fixture.MissingType', @@ -339,6 +341,12 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { // binding (rationale in README.md). ErrorProne (D5) hooks the same compile tasks: it // auto-injects the JDK 16+ --add-exports/--add-opens forking args, so none are added here. tasks.withType(JavaCompile).configureEach { + // Pinned, not inherited from the platform. Sources carry non-ASCII — Korean comments and + // em dashes inside string literals — so a builder whose default charset is not UTF-8 + // compiles different bytes than this one does. It is also what the Gradle model hands the + // IDE as the project encoding; without it every imported project reports "no explicit + // encoding set". + options.encoding = 'UTF-8' ['-parameters', '-Werror', '-Xlint:deprecation', '-Xlint:unchecked'].each { String compilerArg -> if (!options.compilerArgs.contains(compilerArg)) { options.compilerArgs.add(compilerArg) diff --git a/src/sample-portfolio/gradle.lockfile b/src/sample-portfolio/gradle.lockfile index 4995b99..0645f37 100644 --- a/src/sample-portfolio/gradle.lockfile +++ b/src/sample-portfolio/gradle.lockfile @@ -41,6 +41,7 @@ com.google.guava:guava:33.6.0-jre=checkstyle com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.h2database:h2:2.4.240=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.jayway.jsonpath:json-path:2.9.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath