198 lines
12 KiB
Groovy
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).")
|
|
}
|
|
}
|
|
}
|
|
}
|