Files
clean-architecture-backend-…/src/adapter/outbound/cache-redis/build.gradle
T

198 lines
12 KiB
Groovy

plugins {
id 'ca.spring-library'
id 'ca.spring-config'
}
// Redis SDK leaf — see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md.
//
// The design models the SDK as separate Gradle modules. This repository's fail-closed module
// registry outranks that layout, so the module boundaries are packages under
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
dependencies {
// Registered edges the semantic port adapters need. The SDK's *main* source imports nothing from
// them today — the semantic cache/session/idempotency/rate-limit adapters that did were removed
// and are restored by Phase E of
// docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md.
//
// Two of the three are nonetheless load-bearing right now, which the earlier wording hid: this
// leaf declares no `testImplementation project(...)`, so `test` reaches
// dev.caskeleton.application.* and dev.caskeleton.shared.* through these `implementation` edges
// alone. Dropping either breaks compileTestJava today, not at Phase E.
//
// ':adapter:outbound:support' is the one with no reference in any source set; it stays for the
// stated restoration reason, and that is the only one of the three for which that reason is
// doing the work.
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
// The role-aware health contributors are HealthIndicators; the readiness probe is the only
// place the required/optional Redis taxonomy can actually be enforced.
implementation 'org.springframework.boot:spring-boot-health'
// Boot's Health type carries Jackson annotations. Without the annotations on the compile
// classpath javac emits an 'unknown enum constant' warning, and this build is -Werror. Runtime
// does not need it from here — the app already has Jackson — so compileOnly is the honest scope.
compileOnly 'com.fasterxml.jackson.core:jackson-annotations'
implementation 'io.lettuce:lettuce-core'
// Reactor is in the public signature of sdk.api.reactive, and it was reaching this module only
// transitively through lettuce-core. A driver upgrade that stopped exposing it would have
// broken compilation of the SDK's own published API, so it is declared directly.
implementation 'io.projectreactor:reactor-core'
implementation 'org.slf4j:slf4j-api'
// Deliberately absent:
// org.springframework.data:spring-data-redis — the SDK owns its own typed API and command
// policy on purpose; routing through Spring Data would reintroduce the untyped, unguarded
// command surface the catalog exists to prevent. Zero imports.
// io.micrometer:micrometer-core — observation leaves this leaf as RedisObservation through a
// Consumer sink; binding it to a meter registry belongs to the composition root, not here.
// Zero imports.
}
// The topology lane is opt-in and fail-closed. The default unit task excludes it, and selecting it
// without an endpoint is an error rather than a skip: a topology test that silently passes because
// it never connected is worse than not having one.
tasks.named('test') {
useJUnitPlatform {
excludeTags 'redis-topology'
}
}
// Lane selection is derived from the declared mode rather than chosen by hand. A promotion test is
// meaningless without sentinels and a cross-slot test is meaningless without a cluster, but writing
// that as a runtime assumption would turn "the lane was never started" into a green skip. Selecting
// by tag keeps the lane fail-closed: what a mode cannot prove is not selected, and what is selected
// must pass.
//
// The mode is an allowlist, not free text. Deriving the tag from an arbitrary property produced the
// worst possible result for a qualification lane: `-Predis.topology.mode=TYPO` built the tag
// `lane-typo`, matched nothing, ran zero tests and exited 0. A release gate that reports success
// for a lane it never ran is worse than no gate, so an unknown mode is an error and a run that
// executed no test is a failure.
// `tls` is a lane, not a deployment mode. Its shape is standalone; what it qualifies is the
// transport, which no other lane carries a single command over. It was reachable only by hand —
// point LiveRedisCompositionTest at the TLS compose with an ad-hoc init script — which is another
// way of saying the release gate did not cover TLS at all.
def REDIS_TOPOLOGY_MODES = ['standalone', 'sentinel', 'cluster', 'tls'] as Set
def REDIS_TOPOLOGY_DEPLOYMENT_MODE = ['standalone': 'standalone', 'sentinel': 'sentinel',
'cluster': 'cluster', 'tls': 'standalone']
// The classes each lane exists to run. A declaration, not an observation: a lane that lost a class
// to a rename otherwise still reports success on whatever remains.
//
// This list stays hand-written and that is a deliberate refusal, not an oversight. The repository's
// own mechanism for "a named test must actually have run" is `strictTestLanes { lane { requires(…) } }`,
// and ca.strict-test-lane refuses a lane that declares both a tag and required tests — "pick one
// selection". This lane's selection is a tag expression computed from the mode, so `requires` is not
// available to it. Deriving the list instead would mean reading @Tag off the compiled test classes,
// which is a bytecode dependency a leaf build file should not grow.
//
// What did go: REDIS_TOPOLOGY_MINIMUM_TESTS, a per-mode floor of 20/20/24/4 that had to be edited
// whenever a case was added or removed, and whose failure sentence was "coverage shrank" — not a
// runtime, deployment, data, security or compile failure. The class list below covers the case that
// mattered (a lane class silently leaving the lane); the number did not add a second one.
def REDIS_TOPOLOGY_REQUIRED_CLASSES = [
'standalone': ['LiveRedisCompositionTest', 'LiveRedisSemanticPortsTest',
'RedisTopologyContractTest', 'LiveRedisGuardrailTest'],
'sentinel' : ['LiveRedisCompositionTest', 'LiveRedisSentinelPromotionTest',
'RedisTopologyContractTest'],
'cluster' : ['LiveRedisCompositionTest', 'LiveRedisClusterTest',
'LiveRedisClusterTransactionTest', 'LiveRedisSemanticPortsTest'],
'tls' : ['LiveRedisTlsTest'],
]
String declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase()
// Declared through the convention rather than hand-rolled. `ca.strict-test-lane` owns
// testClassesDirs, classpath, the tag filter, failOnNoDiscoveredTests, the refusal to serve an
// up-to-date result, and the "executed nothing" check — the same six things this task spelled out.
// What stays here is what is true of this lane only: the mode allowlist, the endpoint properties and
// the class-coverage check.
//
// The @Tag source-text scan that used to sit in `doFirst` is gone. It read every .java file in the
// test source set looking for the literal strings `@Tag("redis-topology")` and `@Tag("lane-<mode>")`,
// which a comment satisfied and a tag held in a constant defeated — and by its own comment it only
// improved the message for a failure `failOnNoDiscoveredTests` already produces.
strictTestLanes {
lane('redisTopologyTest') {
tag = "redis-topology & lane-${declaredMode}".toString()
description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.'
customize = { test ->
['redis.topology.host', 'redis.topology.port',
'redis.topology.master', 'redis.topology.username', 'redis.topology.password',
'redis.topology.trust-material']
.each { String key ->
if (project.hasProperty(key)) {
test.systemProperty key, project.property(key)
}
}
// The lane name and the deployment mode are different things, and only the TLS lane makes
// that visible: its shape is standalone, so the tests must see `standalone` while the tag
// filter and the required properties come from the lane. Passing the lane name through as
// the mode would fail RedisDeploymentMode.valueOf on a value that is not a topology.
test.systemProperty 'redis.topology.mode',
REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode)
test.systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString()
// Executed, not merely reported. `afterTest` fires for a skipped test too, so counting
// every callback would let a lane whose tests all skipped satisfy the checks below.
def skipped = new java.util.concurrent.atomic.AtomicInteger()
def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
test.afterTest { descriptor, result ->
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
skipped.incrementAndGet()
} else {
classes.add(descriptor.className.tokenize('.').last())
}
}
test.doFirst {
if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) {
throw new GradleException(
"redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " +
'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') +
'. An unrecognised mode selects no test and would otherwise report success.')
}
def required = ['redis.topology.host', 'redis.topology.port']
if (declaredMode == 'sentinel') {
required += 'redis.topology.master'
}
if (declaredMode == 'tls') {
// Without the trust material the client would have to disable verification to
// connect, and a TLS lane that trusts anything qualifies nothing.
required += 'redis.topology.trust-material'
}
def missing = required.findAll { !project.hasProperty(it) }
if (!missing.isEmpty()) {
throw new GradleException(
'redisTopologyTest was selected without ' + missing.join(', ') +
'; start a lane from infra/redis-sdk and pass -P<key>=<value>.')
}
}
test.doLast {
// What a lane must cover, named rather than counted by accident. A tag filter matching
// one trivial class satisfied "ran something" while the class the lane exists for had
// been renamed out of the filter, and nothing said so.
def absent = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode].findAll {
!classes.contains(it)
}
if (!absent.isEmpty()) {
throw new GradleException(
"redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " +
'These classes are what the lane qualifies; a run that skipped them proves ' +
'less than the lane claims.')
}
if (skipped.get() > 0) {
throw new GradleException(
"redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " +
'lane. A qualification lane has no conditional coverage: what it cannot prove ' +
'must not be selected, and what is selected must run.')
}
test.logger.lifecycle(
"redisTopologyTest: ${declaredMode} lane covered ${classes.size()} class(es).")
}
}
}
}